From 8262719f3447caaccf38331088242b9b6b8f00da Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 16:42:48 +1300 Subject: [PATCH 01/11] test(documents): pin deleteDocuments to the documents that still match at delete time deleteDocuments() finds a batch and then deletes it by row id alone, so a document another write changes after the find, so that it no longer matches (a presence refreshed before the expired-presence cleanup deletes it), is deleted anyway, cascades to its children and is counted and handed to $onNext. These tests interleave such a write after the find, in a later batch, under a limit, before a cascade and right before the engine delete, on Memory and SQLite, and pin the adapter delete to the queries it is given. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 50 +++ .../Documents/DeleteDocumentsRaceTest.php | 328 ++++++++++++++++++ .../Documents/Interleaving/Interleaves.php | 51 +++ tests/unit/Documents/Interleaving/Memory.php | 10 + tests/unit/Documents/Interleaving/SQLite.php | 16 + 5 files changed, 455 insertions(+) create mode 100644 tests/unit/Documents/DeleteDocumentsRaceTest.php create mode 100644 tests/unit/Documents/Interleaving/Interleaves.php create mode 100644 tests/unit/Documents/Interleaving/Memory.php create mode 100644 tests/unit/Documents/Interleaving/SQLite.php diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 6335b61bb..e632b7d6a 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -8276,6 +8276,56 @@ public function testDeleteBulkDocumentsQueries(): void $database->deleteCollection('bulk_delete_queries'); } + public function testDeleteBulkDocumentsKeepsDocumentsThatStoppedMatching(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $collectionId = 'bulk_delete_stopped_matching'; + $expired = '2000-01-01T00:00:00.000+00:00'; + $refreshed = '2100-01-01T00:00:00.000+00:00'; + $filter = Query::lessThan('expiresAt', '2050-01-01T00:00:00.000+00:00'); + + $database->createCollection(Collection::create(id: $collectionId, attributes: [ + Attribute::datetime(key: 'expiresAt', required: true), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], documentSecurity: false)); + + foreach (['first' => $expired, 'second' => $expired, 'third' => $expired, 'fourth' => $refreshed] as $id => $expiresAt) { + $database->createDocument($collectionId, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'expiresAt' => $expiresAt, + ])); + } + + $found = $database->find($collectionId, [$filter]); + $this->assertCount(3, $found); + $database->updateDocument($collectionId, 'second', new Document(['expiresAt' => $refreshed])); + + $collection = $database->getCollection($collectionId); + $sequences = []; + $ids = []; + foreach ($found as $document) { + $sequence = $document->getSequence(); + $this->assertNotNull($sequence); + $sequences[] = $sequence; + $ids[] = $document->getId(); + } + + $deleted = $database->getAdapter()->deleteDocuments($collection, $sequences, $ids, $database->convertQueries($collection, [$filter])); + + $this->assertSame(2, $deleted); + $remaining = \array_map(static fn (Document $document): string => $document->getId(), $database->find($collectionId, [Query::orderAsc('$id')])); + $this->assertSame(['fourth', 'second'], $remaining); + $this->assertSame($refreshed, $database->getDocument($collectionId, 'second')->getAttribute('expiresAt')); + + $database->deleteCollection($collectionId); + } + public function testDeleteBulkDocumentsWithCallbackSupport(): void { /** @var Database $database */ diff --git a/tests/unit/Documents/DeleteDocumentsRaceTest.php b/tests/unit/Documents/DeleteDocumentsRaceTest.php new file mode 100644 index 000000000..01fb10d76 --- /dev/null +++ b/tests/unit/Documents/DeleteDocumentsRaceTest.php @@ -0,0 +1,328 @@ + + */ + public static function adapters(): iterable + { + yield 'memory' => [static fn (): Memory => new Memory()]; + yield 'sqlite' => [static fn (): SQLite => SQLite::inMemory()]; + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testADocumentChangedAfterTheFindIsKept(Closure $adapter): void + { + [$database, $engine] = $this->database($adapter()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED, 'd' => self::REFRESHED]); + $engine->afterFind = $this->refreshOnce($database, 'b'); + + $deleted = []; + $count = $database->deleteDocuments(self::COLLECTION, [$this->expired()], onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + }); + + $this->assertSame(2, $count, 'The refreshed document is not counted'); + $this->assertSame(['a', 'c'], $deleted, 'The refreshed document is not handed to $onNext'); + $this->assertSame(['b', 'd'], $this->stored($database)); + $this->assertSame(self::REFRESHED, $this->expiry($database, 'b')); + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testADocumentChangedAfterALaterBatchIsFoundIsKept(Closure $adapter): void + { + [$database, $engine] = $this->database($adapter()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED, 'd' => self::EXPIRED, 'e' => self::EXPIRED, 'f' => self::REFRESHED]); + $engine->afterFind = $this->refreshOnce($database, 'd'); + + $deleted = []; + $count = $database->deleteDocuments(self::COLLECTION, [$this->expired()], batchSize: 2, onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + }); + + $this->assertSame(4, $count); + $this->assertSame(['a', 'b', 'c', 'e'], $deleted); + $this->assertSame(['d', 'f'], $this->stored($database)); + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testTheLimitCountsTheDocumentsFoundSoAKeptDocumentIsNotReplaced(Closure $adapter): void + { + [$database, $engine] = $this->database($adapter()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED, 'd' => self::EXPIRED, 'e' => self::EXPIRED]); + $engine->afterFind = $this->refreshOnce($database, 'b'); + + $count = $database->deleteDocuments(self::COLLECTION, [$this->expired(), Query::limit(4)], batchSize: 2); + + $this->assertSame(3, $count); + $this->assertSame(['b', 'e'], $this->stored($database), 'Only the first four documents found are deleted, less the one refreshed'); + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testACascadeSkipsTheChildrenOfADocumentChangedAfterTheFind(Closure $adapter): void + { + [$database, $engine] = $this->database($adapter()); + $database->createCollection(Collection::create(self::CHILDREN, permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship(self::COLLECTION, Relationship::oneToMany(self::CHILDREN, key: 'sessions', twoWay: true, twoWayKey: 'presence', onDelete: RelationshipDeleteAction::Cascade)); + $database->createDocument(self::CHILDREN, new Document([Document::ID => 'a-session'])); + $database->createDocument(self::CHILDREN, new Document([Document::ID => 'b-session'])); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED], ['a' => ['a-session'], 'b' => ['b-session']]); + $engine->afterFind = $this->refreshOnce($database, 'b'); + + $count = $database->deleteDocuments(self::COLLECTION, [$this->expired()]); + + $this->assertSame(1, $count); + $this->assertSame(['b'], $this->stored($database)); + $this->assertSame(['b-session'], $this->stored($database, self::CHILDREN), 'The refreshed document keeps its children'); + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testADocumentChangedRightBeforeTheDeleteRollsTheBatchBack(Closure $adapter): void + { + [$database, $engine] = $this->database($adapter()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED]); + $engine->beforeDelete = function (Document $collection) use ($database): void { + if ($collection->getId() === self::COLLECTION) { + $database->updateDocument(self::COLLECTION, 'b', new Document(['expiresAt' => self::REFRESHED])); + } + }; + + try { + $database->deleteDocuments(self::COLLECTION, [$this->expired()]); + $this->fail('A document that stops matching between the check and the delete must fail the batch'); + } catch (ContentionException) { + } + + $this->assertSame(3, $engine->deletes, 'The batch is tried again before it fails'); + $this->assertSame(['a', 'b', 'c'], $this->stored($database), 'Nothing of the failed batch is stored'); + $this->assertSame(self::EXPIRED, $this->expiry($database, 'b')); + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testTheAdapterDeletesOnlyTheRowsThatStillMatchAndKeepsTheirPermissions(Closure $adapter): void + { + [$database, $engine] = $this->database($adapter()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED, 'd' => self::REFRESHED]); + $found = $database->find(self::COLLECTION, [$this->expired()]); + $database->updateDocument(self::COLLECTION, 'b', new Document(['expiresAt' => self::REFRESHED])); + + $collection = $database->getCollection(self::COLLECTION); + $sequences = \array_values(\array_filter(\array_map(static fn (Document $document): ?string => $document->getSequence(), $found))); + $permissionIds = \array_map(static fn (Document $document): string => $document->getId(), $found); + + $count = $engine->deleteDocuments($collection, $sequences, $permissionIds, $database->convertQueries($collection, [$this->expired()])); + + $this->assertSame(2, $count); + $this->assertSame(['b', 'd'], $this->stored($database)); + $this->assertSame(['b', 'd'], \array_map(static fn (Document $document): string => $document->getId(), $database->find(self::COLLECTION)), 'The kept row keeps its permission rows'); + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testADeleteWithNoRaceDeletesEveryMatchingDocumentAndItsPermissions(Closure $adapter): void + { + [$database, $engine] = $this->database($adapter()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED, 'd' => self::REFRESHED]); + + $deleted = []; + $count = $database->deleteDocuments(self::COLLECTION, [$this->expired()], batchSize: 2, onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + }); + + $this->assertSame(3, $count); + $this->assertSame(['a', 'b', 'c'], $deleted); + $this->assertSame(2, $engine->deletes); + $this->assertSame(['d'], $this->stored($database)); + + $database->getAuthorization()->skip(fn (): Document => $database->createDocument(self::COLLECTION, new Document([ + Document::ID => 'a', + Document::PERMISSIONS => [], + 'expiresAt' => self::EXPIRED, + ]))); + $this->assertSame(['d'], \array_map(static fn (Document $document): string => $document->getId(), $database->find(self::COLLECTION)), 'A deleted document leaves no permission rows behind'); + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testADeleteWithoutFiltersDeletesEveryDocument(Closure $adapter): void + { + [$database, $engine] = $this->database($adapter()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::REFRESHED, 'c' => self::EXPIRED]); + + $this->assertSame(3, $database->deleteDocuments(self::COLLECTION, batchSize: 2)); + $this->assertSame([], $this->stored($database)); + $this->assertSame(2, $engine->deletes); + } + + /** + * @return array{Database, Memory|SQLite} + */ + private function database(Memory|SQLite $adapter): array + { + $authorization = new Authorization(); + $authorization->addRole(self::OWNER); + + $database = new Database($adapter, new Cache(new None())); + $database + ->setAuthorization($authorization) + ->setDatabase('race') + ->setNamespace('race_'.\uniqid()); + $database->create(); + $database->addHook(new Relationships()); + $database->addHook(new Permissions()); + + $database->createCollection(Collection::create( + id: self::COLLECTION, + attributes: [Attribute::datetime(key: 'expiresAt')], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + + return [$database, $adapter]; + } + + /** + * @return list + */ + private function permissions(): array + { + return [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + } + + /** + * @param array $expiries + * @param array> $sessions + */ + private function seed(Database $database, array $expiries, array $sessions = []): void + { + foreach ($expiries as $id => $expiresAt) { + $attributes = [ + Document::ID => $id, + Document::PERMISSIONS => [ + Permission::read(Role::user('owner')), + Permission::update(Role::user('owner')), + Permission::delete(Role::user('owner')), + ], + 'expiresAt' => $expiresAt, + ]; + if (isset($sessions[$id])) { + $attributes['sessions'] = $sessions[$id]; + } + $database->createDocument(self::COLLECTION, new Document($attributes)); + } + } + + /** + * @return Closure(Document, array): void + */ + private function refreshOnce(Database $database, string $id): Closure + { + $refreshed = false; + + return function (Document $collection, array $documents) use ($database, $id, &$refreshed): void { + if ($refreshed || $collection->getId() !== self::COLLECTION) { + return; + } + foreach ($documents as $document) { + if ($document->getId() === $id) { + $refreshed = true; + $database->updateDocument(self::COLLECTION, $id, new Document(['expiresAt' => self::REFRESHED])); + + return; + } + } + }; + } + + private function expired(): Query + { + return Query::lessThan('expiresAt', self::NOW); + } + + /** + * @return list + */ + private function stored(Database $database, string $collection = self::COLLECTION): array + { + $ids = $database->getAuthorization()->skip(fn (): array => $database->skipRelationships(fn (): array => \array_map( + static fn (Document $document): string => $document->getId(), + $database->find($collection), + ))); + \sort($ids); + + return $ids; + } + + private function expiry(Database $database, string $id): mixed + { + return $database->getAuthorization()->skip(fn (): Document => $database->getDocument(self::COLLECTION, $id))->getAttribute('expiresAt'); + } +} diff --git a/tests/unit/Documents/Interleaving/Interleaves.php b/tests/unit/Documents/Interleaving/Interleaves.php new file mode 100644 index 000000000..4afa4b8c5 --- /dev/null +++ b/tests/unit/Documents/Interleaving/Interleaves.php @@ -0,0 +1,51 @@ +): void)|null + */ + public ?Closure $afterFind = null; + + /** + * @var (Closure(Document): void)|null + */ + public ?Closure $beforeDelete = null; + + public int $deletes = 0; + + #[\Override] + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array + { + $documents = parent::find($collection, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission); + + if ($this->afterFind !== null) { + ($this->afterFind)($collection, $documents); + } + + return $documents; + } + + #[\Override] + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int + { + $this->deletes++; + + if ($this->beforeDelete !== null) { + ($this->beforeDelete)($collection); + } + + return parent::deleteDocuments($collection, $sequences, $permissionIds, $queries); + } +} diff --git a/tests/unit/Documents/Interleaving/Memory.php b/tests/unit/Documents/Interleaving/Memory.php new file mode 100644 index 000000000..4d7409c1c --- /dev/null +++ b/tests/unit/Documents/Interleaving/Memory.php @@ -0,0 +1,10 @@ + Date: Thu, 15 Oct 2026 17:20:36 +1300 Subject: [PATCH 02/11] fix(documents): delete only the documents that still match when the batch is deleted deleteDocuments() found each batch and then deleted it by internal id alone, outside the transaction that deleted it, so a document another write changed in between so that it no longer matched (a presence refreshed before the expired-presence cleanup deletes it) was still deleted, cascaded to its related documents, lost its permission rows, and was counted and handed to $onNext. A batch with filters now reads its documents again in the deleting transaction, by sequence plus the caller's selections and filters, so cascades, permission ids, the count, $onNext and the cache purges cover only those. The adapter delete takes the filters as find() was handed them (fetch() hands them back) and applies them in the delete itself: SQL compiles them through the same applyFilters() as count(), Mongo ands them into the delete filter, Memory and Redis re-check each row. A shortfall against the re-read set means a document changed between the re-read and the delete, after its cascades ran, so the batch throws Contention and is rolled back and retried rather than committing a partial cascade. Adapters also keep the permission rows of a row kept for no longer matching. A limit now counts the documents found rather than deleted, so a kept document is not replaced by one past the original window and the loop cannot run past the limit. Deletes without filters take the old path unchanged. Adapter::deleteDocuments() gains an optional $queries parameter; Pool forwards it. Co-Authored-By: Claude Opus 5.5 --- src/Database/Adapter.php | 7 +- src/Database/Adapter/Memory.php | 31 ++++- src/Database/Adapter/Mongo.php | 14 ++- src/Database/Adapter/Pool.php | 2 +- src/Database/Adapter/Redis.php | 22 +++- src/Database/Adapter/SQL.php | 57 ++++++++- src/Database/Trait/Documents.php | 117 ++++++++++++++++-- .../Documents/DeleteDocumentsRaceTest.php | 1 + tests/unit/MirrorTest.php | 4 +- 9 files changed, 225 insertions(+), 30 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 769bba3ea..6a0648aea 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -771,10 +771,15 @@ abstract public function increaseDocumentAttribute( abstract public function deleteDocument(Document $collection, string $id): bool; /** + * Delete the rows of the sequences given that match every filter query, and the permission rows of the + * permission ids given, but those of a row kept because it no longer matches. + * * @param array $sequences * @param array $permissionIds + * @param array $queries Filter queries as find() is handed them, which every deleted row must match + * @return int The number of rows deleted */ - abstract public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int; + abstract public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int; /** * Find Documents diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index a348f8974..fa5837674 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -1726,8 +1726,13 @@ public function deleteDocument(Document $collection, string $id): bool return true; } + /** + * @param array $sequences + * @param array $permissionIds + * @param array $queries + */ #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int { $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { @@ -1739,8 +1744,10 @@ public function deleteDocuments(Document $collection, array $sequences, array $p $seqSet[(string) $seq] = true; } + $filters = Query::groupByType($queries)->filters; $count = 0; $deletedIds = []; + $keptIds = []; foreach ($this->data[$key]['documents'] as $docKey => $row) { // With sharedTables the row map is keyed by "tenant|uid" so sequence // collisions across tenants are possible. Skip rows that don't belong @@ -1751,6 +1758,11 @@ public function deleteDocuments(Document $collection, array $sequences, array $p $rowId = $row[Storage::SEQUENCE] ?? ''; $rowUid = $row[Storage::UID] ?? $docKey; if (isset($seqSet[\is_scalar($rowId) ? (string) $rowId : ''])) { + if (! $this->matchesAll($row, $filters)) { + $keptIds[\is_scalar($rowUid) ? (string) $rowUid : $docKey] = true; + + continue; + } $deletedIds[\is_scalar($rowUid) ? (string) $rowUid : $docKey] = true; $oldSignatures = $this->rowUniqueSignatures($key, $row); unset($this->data[$key]['documents'][$docKey]); @@ -1779,7 +1791,7 @@ public function deleteDocuments(Document $collection, array $sequences, array $p $this->removePermissionsForDocument($key, (string) $documentId, $tenant, $this->sharedTables); } foreach (\array_keys($permSet) as $documentId) { - if (isset($deletedIds[$documentId])) { + if (isset($deletedIds[$documentId]) || isset($keptIds[$documentId])) { continue; } $this->removePermissionsForDocument($key, (string) $documentId, $tenant, $this->sharedTables); @@ -2432,6 +2444,21 @@ protected function fusedFilter(string $key, string $collectionId, array $queries return $output; } + /** + * @param array $row + * @param array $queries + */ + private function matchesAll(array $row, array $queries): bool + { + foreach ($queries as $query) { + if (! $this->matches($row, $query)) { + return false; + } + } + + return true; + } + /** * @param array $row */ diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 2a407912c..88a523503 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2292,25 +2292,29 @@ public function deleteDocument(Document $collection, string $id): bool /** * @param array $sequences * @param array $permissionIds + * @param array $queries * * @throws DatabaseException */ #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int { $collectionId = $collection->getId(); $name = $this->getNamespace().'_'.$this->filter($collectionId); - foreach ($sequences as $index => $sequence) { - $sequences[$index] = $sequence; - } - /** @var array $filters */ $filters = $this->buildFilters([new Query(Method::Equal, Storage::SEQUENCE, $sequences)]); $filters = $this->applyTenantFilter($filters, $collectionId); $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); + $queries = \array_map(static fn (Query $query): Query => clone $query, $queries); + $this->escapeQueryAttributes($collection, $queries); + $matching = $this->buildFilters($queries); + if ($matching !== []) { + $filters = ['$and' => [$filters, $matching]]; + } + $options = $this->getTransactionOptions(); try { diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index a05f4efcc..cf5ee7ae9 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -865,7 +865,7 @@ public function deleteDocument(Document $collection, string $id): bool } #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int { /** @var int $result */ $result = $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 457cc7ade..dc08c2c76 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -1392,8 +1392,13 @@ public function deleteDocument(Document $collection, string $id): bool }); } + /** + * @param array $sequences + * @param array $permissionIds + * @param array $queries + */ #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int { if (empty($sequences) && empty($permissionIds)) { return 0; @@ -1402,7 +1407,7 @@ public function deleteDocuments(Document $collection, array $sequences, array $p $collectionId = $this->filter($collection->getId()); $idxKey = $this->idxKey($collectionId); - return $this->transaction(function (RedisClient $redis) use ($collectionId, $sequences, $permissionIds, $idxKey): int { + return $this->transaction(function (RedisClient $redis) use ($collectionId, $sequences, $permissionIds, $queries, $idxKey): int { $sequenceSet = []; foreach ($sequences as $sequence) { $sequenceSet[(string) $sequence] = true; @@ -1426,6 +1431,7 @@ public function deleteDocuments(Document $collection, array $sequences, array $p } $deleted = []; + $kept = []; foreach ($allIds as $position => $id) { $payload = $payloads[$position] ?? false; if (! \is_string($payload) || $payload === '') { @@ -1433,9 +1439,15 @@ public function deleteDocuments(Document $collection, array $sequences, array $p } $document = $this->decode($payload); $matchesSequence = isset($sequenceSet[(string) $document->getSequence()]); - if ($matchesSequence) { - $deleted[$document->getId()] = ['payload' => $payload, 'docKey' => $docKeys[(string) $id]]; + if (! $matchesSequence) { + continue; + } + if ($this->filterDocumentsByQueries($collectionId, [$document], $queries) === []) { + $kept[$document->getId()] = true; + + continue; } + $deleted[$document->getId()] = ['payload' => $payload, 'docKey' => $docKeys[(string) $id]]; } foreach ($deleted as $documentId => $deleteEntry) { @@ -1454,7 +1466,7 @@ public function deleteDocuments(Document $collection, array $sequences, array $p foreach ($permissionIds as $permissionId) { $documentId = (string) $permissionId; - if (isset($deleted[$documentId])) { + if (isset($deleted[$documentId]) || isset($kept[$documentId])) { continue; } $this->clearPermissions($collectionId, $documentId); diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 4bb7215ec..9f778849c 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1259,11 +1259,12 @@ public function upsertDocuments(Document $collection, array $changes, ?string $i /** * @param array $sequences * @param array $permissionIds + * @param array $queries * * @throws DatabaseException */ #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int { if (empty($sequences)) { return 0; @@ -1273,9 +1274,14 @@ public function deleteDocuments(Document $collection, array $sequences, array $p try { $name = $this->filter($collection->getId()); + $filters = \array_values(\array_filter( + $this->mainRowQueries($queries, $collection), + static fn (Query $query): bool => ! $query->getMethod()->isVector(), + )); $builder = $this->newBuilder($name); $builder->filter([BaseQuery::equal(Storage::SEQUENCE, \array_values($sequences))]); + $this->applyFilters($builder, $filters, $name, $this->unqualifiedTable($name)); $result = $builder->delete(); $statement = $this->executeResult($result, Event::DocumentsDelete); @@ -1283,13 +1289,60 @@ public function deleteDocuments(Document $collection, array $sequences, array $p throw new DatabaseException('Failed to delete documents'); } + $deleted = $statement->rowCount(); + if ($filters !== [] && $deleted < \count($sequences)) { + $permissionIds = $this->withoutStoredDocuments($name, $permissionIds); + } + $context = $this->writeContext(); $this->runWriteHooks(fn ($hook) => $hook->afterDocumentDelete($name, \array_values($permissionIds), $context)); } catch (Throwable $e) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } - return $statement->rowCount(); + return $deleted; + } + + /** + * The name a statement on the collection's table refers to its columns by when it gives the table no alias. + * + * @throws DatabaseException + */ + private function unqualifiedTable(string $name): string + { + $table = $this->getTableRaw($name); + $dot = \strrpos($table, '.'); + + return $dot === false ? $table : \substr($table, $dot + 1); + } + + /** + * The document ids given whose row is no longer stored. + * + * @param array $documentIds + * @return array + * + * @throws DatabaseException + */ + private function withoutStoredDocuments(string $name, array $documentIds): array + { + if ($documentIds === []) { + return []; + } + + $builder = $this->newBuilder($name); + $builder->select([Storage::UID]); + $builder->filter([BaseQuery::equal(Storage::UID, \array_values($documentIds))]); + + $stored = []; + foreach ($this->executeSelect($builder, Event::DocumentFind, $name) as $row) { + $id = $row[Storage::UID] ?? null; + if (\is_string($id)) { + $stored[$id] = true; + } + } + + return \array_values(\array_filter($documentIds, static fn (string $id): bool => ! isset($stored[$id]))); } /** diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 93da4a57e..296ca7aef 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -26,6 +26,7 @@ use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Conflict as ConflictException; +use Utopia\Database\Exception\Contention as ContentionException; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\NotFound as NotFoundException; @@ -43,6 +44,7 @@ use Utopia\Database\Id; use Utopia\Database\Operator; use Utopia\Database\OperatorType; +use Utopia\Database\ParsedQuery; use Utopia\Database\PermissionType; use Utopia\Database\Query; use Utopia\Database\RelationshipSide; @@ -3056,6 +3058,10 @@ private function dispatchDeleted(string $collection, Document $document, array $ /** * Deletes all documents which match the given queries, respecting relationship onDelete options. * + * Each batch deletes only the documents that still match the filters when it is deleted: a document another write + * changed after it was found, so that it no longer matches, is kept, and is neither counted, handed to $onNext nor + * cascaded. A limit counts the documents found, kept ones included. + * * @param string $collection The collection identifier * @param array $queries Queries to filter documents for deletion * @param int $batchSize Number of documents per batch deletion, at most BATCH_SIZE @@ -3070,6 +3076,7 @@ private function dispatchDeleted(string $collection, Document $document, array $ * @throws LimitException When $batchSize is above BATCH_SIZE * @throws QueryException * @throws RestrictedException + * @throws ContentionException When documents keep changing between the check and the delete of a batch * @throws Throwable */ public function deleteDocuments( @@ -3115,6 +3122,7 @@ public function deleteDocuments( $originalLimit = $limit; $last = $cursor; + $found = 0; $modified = 0; while (true) { @@ -3146,12 +3154,15 @@ public function deleteDocuments( break; } - $sequences = []; - $permissionIds = []; - $cacheTarget = $collection->getId() === self::METADATA ? $batch : $collection->getId(); - $this->withMutation(Event::DocumentsDelete, $cacheTarget, function () use ($collection, $sequences, $permissionIds, $batch) { - foreach ($batch as $document) { + $deleted = $this->withMutation(Event::DocumentsDelete, $cacheTarget, function () use ($collection, $batch, $parsed): array { + $filters = []; + $documents = $parsed->filters === [] ? $batch : $this->stillMatching($collection, $batch, $parsed, $filters); + + $sequences = []; + $permissionIds = []; + + foreach ($documents as $document) { $seq = $document->getSequence(); if ($seq !== null) { $sequences[] = $seq; @@ -3180,32 +3191,45 @@ public function deleteDocuments( } } - $this->adapter->deleteDocuments( + if ($documents === []) { + return []; + } + + $count = $this->adapter->deleteDocuments( $collection, $sequences, - $permissionIds + $permissionIds, + $filters, ); - foreach ($batch as $document) { + if ($filters !== [] && $count !== \count($sequences)) { + throw new ContentionException('Documents were changed while they were being deleted'); + } + + foreach ($documents as $document) { $this->withDocumentTenant( $document, fn () => $this->advanceCollectionCacheEpoch($collection->getId(), $document->getId()) ); } - $this->queueDocumentPurges($collection->getId(), $batch); + $this->queueDocumentPurges($collection->getId(), $documents); + + return $documents; }); - foreach ($batch as $document) { + foreach ($deleted as $document) { if ($onNext !== null) { $onNext($document, clone $document); } $modified++; } + $found += \count($batch); + if (count($batch) < $batchSize) { break; - } elseif ($originalLimit && $modified >= $originalLimit) { + } elseif ($originalLimit && $found >= $originalLimit) { break; } @@ -3220,6 +3244,69 @@ public function deleteDocuments( return $modified; } + /** + * The documents of a batch deleteDocuments() found that still match its queries, read again in the transaction + * that deletes them, in the order they were found, with the filters as the adapter applies them. + * + * @param array $batch + * @param array $filters + * @param-out array $filters + * @return list + * + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + private function stillMatching(Collection $collection, array $batch, ParsedQuery $parsed, array &$filters): array + { + $filters = []; + $sequences = []; + foreach ($batch as $document) { + $sequence = $document->getSequence(); + if ($sequence !== null) { + $sequences[] = $sequence; + } + } + + if ($sequences === []) { + return []; + } + + $queries = [ + Query::equal(Document::SEQUENCE, $sequences), + Query::limit(\count($sequences)), + ...$parsed->selections, + ...$parsed->filters, + ]; + + /** @var array{array, array|null} $read */ + $read = $this->silent(fn () => $this->skipQueryHooks(fn () => $this->skipValidation(function () use ($collection, $queries): array { + $documents = $this->fetch($collection, $queries, PermissionType::Delete, false, $adapterQueries); + + return [$documents, $adapterQueries]; + }))); + [$current, $adapterQueries] = $read; + + if ($adapterQueries !== null) { + $filters = Query::groupByType($adapterQueries)->filters; + } + + $matching = []; + foreach ($current as $document) { + $matching[(string) $document->getSequence()] = $document; + } + + $documents = []; + foreach ($sequences as $sequence) { + if (isset($matching[$sequence])) { + $documents[] = $matching[$sequence]; + } + } + + return $documents; + } + /** * Cleans all of the collection's documents from the cache and all related cached documents. */ @@ -4001,6 +4088,9 @@ public function find(string $collection, array $queries = [], PermissionType $fo /** * @param array $queries * @param bool $aggregate Whether the queries must aggregate (aggregate()) or must not (find()) + * @param array|null $adapterQueries Receives the queries as the adapter's find() was handed them, or + * null when they can match no document and the adapter is not read + * @param-out array|null $adapterQueries * @return array * * @throws DatabaseException @@ -4008,8 +4098,10 @@ public function find(string $collection, array $queries = [], PermissionType $fo * @throws TimeoutException * @throws Exception */ - private function fetch(Collection $collection, array $queries, PermissionType $forPermission, bool $aggregate): array + private function fetch(Collection $collection, array $queries, PermissionType $forPermission, bool $aggregate, ?array &$adapterQueries = null): array { + $adapterQueries = null; + $queryCacheQueries = $queries; $this->checkQueryTypes($queries); @@ -4174,6 +4266,7 @@ private function fetch(Collection $collection, array $queries, PermissionType $f $results = []; } else { $queries = $convertedQueries; + $adapterQueries = $queries; $cacheEntry = null; $cacheGeneration = ''; diff --git a/tests/unit/Documents/DeleteDocumentsRaceTest.php b/tests/unit/Documents/DeleteDocumentsRaceTest.php index 01fb10d76..a03467a7d 100644 --- a/tests/unit/Documents/DeleteDocumentsRaceTest.php +++ b/tests/unit/Documents/DeleteDocumentsRaceTest.php @@ -287,6 +287,7 @@ private function refreshOnce(Database $database, string $id): Closure { $refreshed = false; + /** @param array $documents */ return function (Document $collection, array $documents) use ($database, $id, &$refreshed): void { if ($refreshed || $collection->getId() !== self::COLLECTION) { return; diff --git a/tests/unit/MirrorTest.php b/tests/unit/MirrorTest.php index 0206e3479..c213a76af 100644 --- a/tests/unit/MirrorTest.php +++ b/tests/unit/MirrorTest.php @@ -1807,11 +1807,11 @@ public function deleteDocument(Document $collection, string $id): bool } #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int { $this->reach(); - return parent::deleteDocuments($collection, $sequences, $permissionIds); + return parent::deleteDocuments($collection, $sequences, $permissionIds, $queries); } private function reach(): void From d0eca5c0d70cc88f82a8feaf8e2d5177bd98f772 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 17:20:36 +1300 Subject: [PATCH 03/11] docs: note the 8.1.4 deleteDocuments fix and the adapter parameter The fix changes which documents deleteDocuments() deletes under a race, and custom adapters overriding Adapter::deleteDocuments() must add the new $queries parameter or PHP refuses the override. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 +++++++++++++++++++++++ UPGRADE.md | 16 ++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6820d0021..a086c2554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 8.1.4 + +8.1.4 fixes `deleteDocuments()` deleting documents that stopped matching its queries after they were found. Custom +adapters that implement `Adapter::deleteDocuments()` add a parameter; see +[Upgrading from 8.1.3 to 8.1.4](UPGRADE.md#upgrading-from-813-to-814). + +### Fixed + +- `deleteDocuments()` deletes only the documents that still match its filters when each batch is deleted. It found a + batch and then deleted it by internal id alone, so a document another write changed in between, so that it no + longer matched (such as a presence refreshed before an expired-presence cleanup deleted it), was deleted anyway, + cascaded to its related documents, lost its permission rows, and was counted and handed to `onNext`. Each batch + with filters now reads its documents again, with the same queries, in the transaction that deletes them, and the + adapter deletes only the rows that match the filters there. A document that no longer matches is kept with its + related documents and permission rows, and is neither counted, handed to `onNext` nor in `Event::DocumentsDelete`'s + count. A limit counts the documents found, kept ones included, so a kept document is not replaced by a later one. + When a document changes between that read and the delete, the batch is rolled back and tried again, and fails with + `Exception\Contention` once the transaction's retries are spent. A delete without filters runs as before. +- `Adapter::deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int` + takes the filter queries every deleted row must match, as `find()` is handed them, and returns the number of rows + deleted. Memory, SQLite, MariaDB, MySQL, PostgreSQL, MongoDB and Redis apply them in the delete itself; `Pool` and + `ReadWritePool` pass them on. A row kept because it no longer matches keeps its permission rows. + ## 8.1.3 8.1.3 adds a hook that rewrites the queries of a read. It changes nothing for a database without one; see diff --git a/UPGRADE.md b/UPGRADE.md index fe5d03261..9cae244d5 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,3 +1,19 @@ +# Upgrading from 8.1.3 to 8.1.4 + +8.1.4 is a patch release. `deleteDocuments()` keeps its signature and now deletes only the documents that still match +its filters when they are deleted; see [CHANGELOG.md](CHANGELOG.md#814). + +A custom adapter that extends `Adapter` and implements `deleteDocuments()`, or a subclass of a bundled adapter or +`Pool` that overrides it, adds the new last parameter, as PHP refuses an override that drops it: + +```php +public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int +``` + +Delete only the rows of `$sequences` that match every query in `$queries` (filter queries, as `find()` is handed them), +remove the permission rows of `$permissionIds` but those of a row kept because it no longer matches, and return the +number of rows deleted. An override that delegates passes `$queries` on to its parent. + # Upgrading from 8.1.2 to 8.1.3 8.1.3 is a patch release and needs no code change. It adds `Hook\Queries`, which rewrites the queries of a read From 7319e2f6c124b938ba2fb957680c34fe0f99f6bf Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 17:23:17 +1300 Subject: [PATCH 04/11] test(documents): type the interleaved refresh so phpstan sees its documents PHPStan reads no type for an inline closure's array parameter, so the race tests' refresh moves into an invokable with a typed __invoke() that hands its first-class callable to the adapter's afterFind seam. Co-Authored-By: Claude Opus 5.5 --- .../Documents/DeleteDocumentsRaceTest.php | 18 +------- tests/unit/Documents/Interleaving/Refresh.php | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 16 deletions(-) create mode 100644 tests/unit/Documents/Interleaving/Refresh.php diff --git a/tests/unit/Documents/DeleteDocumentsRaceTest.php b/tests/unit/Documents/DeleteDocumentsRaceTest.php index a03467a7d..62429b955 100644 --- a/tests/unit/Documents/DeleteDocumentsRaceTest.php +++ b/tests/unit/Documents/DeleteDocumentsRaceTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tests\Unit\Documents\Interleaving\Memory; +use Tests\Unit\Documents\Interleaving\Refresh; use Tests\Unit\Documents\Interleaving\SQLite; use Utopia\Cache\Adapter\None; use Utopia\Cache\Cache; @@ -285,22 +286,7 @@ private function seed(Database $database, array $expiries, array $sessions = []) */ private function refreshOnce(Database $database, string $id): Closure { - $refreshed = false; - - /** @param array $documents */ - return function (Document $collection, array $documents) use ($database, $id, &$refreshed): void { - if ($refreshed || $collection->getId() !== self::COLLECTION) { - return; - } - foreach ($documents as $document) { - if ($document->getId() === $id) { - $refreshed = true; - $database->updateDocument(self::COLLECTION, $id, new Document(['expiresAt' => self::REFRESHED])); - - return; - } - } - }; + return (new Refresh($database, self::COLLECTION, $id, ['expiresAt' => self::REFRESHED]))(...); } private function expired(): Query diff --git a/tests/unit/Documents/Interleaving/Refresh.php b/tests/unit/Documents/Interleaving/Refresh.php new file mode 100644 index 000000000..27833d8e5 --- /dev/null +++ b/tests/unit/Documents/Interleaving/Refresh.php @@ -0,0 +1,44 @@ + $changes + */ + public function __construct( + private readonly Database $database, + private readonly string $collection, + private readonly string $id, + private readonly array $changes, + ) { + } + + /** + * @param array $documents + */ + public function __invoke(Document $collection, array $documents): void + { + if ($this->done || $collection->getId() !== $this->collection) { + return; + } + + foreach ($documents as $document) { + if ($document->getId() === $this->id) { + $this->done = true; + $this->database->updateDocument($this->collection, $this->id, new Document($this->changes)); + + return; + } + } + } +} From 046eb780af6ed0b4b9bc821c35e856c008438b9c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 18:07:08 +1300 Subject: [PATCH 05/11] fix(documents): keep deleteDocuments a patch on every adapter The filtered delete bound its floats exactly while find() binds them in fixed-point notation, so a filter such as greaterThan('score', 1e-20) found a row the delete then refused to delete, and every such batch failed with a contention no concurrent write caused. A bulk delete now binds floats as find() does. Without transactions, as on a standalone MongoDB server, a contention abandoned deletes and cascades already applied without reporting them. There, the documents still stored after the delete are left out of the count, onNext, the cache purges and the event, and the remaining batches still run. Adding a parameter to the abstract Adapter::deleteDocuments() broke every custom adapter in a patch release. It keeps its 8.1.3 signature; the new Adapter::deleteDocumentsMatching() scopes the filters around a call to deleteDocuments(), so an override of deleteDocuments() in a subclass of a bundled adapter still runs, and a custom adapter that ignores the filters deletes as before. Pool delegates it so the scope lands on the pooled adapter. A batch filtered only on the immutable $sequence is not read again, a contention in a later batch still reports the batches already deleted, and the in-savepoint retry under a manual outer transaction is documented, as MySQL and MariaDB reread the same snapshot there. Co-Authored-By: Claude Opus 5.5 --- src/Database/Adapter.php | 47 +++- src/Database/Adapter/Memory.php | 15 +- src/Database/Adapter/Mongo.php | 7 +- src/Database/Adapter/Pool.php | 10 +- src/Database/Adapter/Redis.php | 6 +- src/Database/Adapter/SQL.php | 16 +- src/Database/Exception/Contention.php | 4 + src/Database/Trait/Documents.php | 227 +++++++++++++----- tests/e2e/Adapter/Scopes/DocumentTests.php | 2 +- .../Documents/DeleteDocumentsRaceTest.php | 2 +- .../Documents/Interleaving/Interleaves.php | 10 +- tests/unit/MirrorTest.php | 4 +- 12 files changed, 259 insertions(+), 91 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 6a0648aea..22525c2c9 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -42,6 +42,9 @@ abstract class Adapter /** @var Value|null */ private ?Value $ignoringDuplicates = null; + /** @var Value>|null */ + private ?Value $deletionFilters = null; + protected bool $tenantPerDocument = false; protected int $inTransaction = 0; @@ -770,16 +773,56 @@ abstract public function increaseDocumentAttribute( abstract public function deleteDocument(Document $collection, string $id): bool; + /** + * @param array $sequences + * @param array $permissionIds + */ + abstract public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int; + /** * Delete the rows of the sequences given that match every filter query, and the permission rows of the - * permission ids given, but those of a row kept because it no longer matches. + * permission ids given but those of a row kept because it no longer matches. Database::deleteDocuments() calls + * it for a batch it deletes with filters, so that a document another write changed after it was found is kept. + * + * This implementation calls deleteDocuments() with the queries as its deletionFilters(), so that an override of + * deleteDocuments() still runs. The bundled adapters delete only the rows that match them; an adapter that + * ignores them deletes every row of the sequences given, as before. An adapter that extends Adapter directly + * honours them by reading deletionFilters() in deleteDocuments(), or by overriding this method. * * @param array $sequences * @param array $permissionIds * @param array $queries Filter queries as find() is handed them, which every deleted row must match * @return int The number of rows deleted */ - abstract public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int; + public function deleteDocumentsMatching(Document $collection, array $sequences, array $permissionIds, array $queries): int + { + return $this->deletionFiltersValue()->with( + $queries, + fn (): int => $this->deleteDocuments($collection, $sequences, $permissionIds), + ); + } + + /** + * The filter queries every row deleteDocuments() deletes must match: those deleteDocumentsMatching() was handed + * while it runs, else none. + * + * @return array + */ + protected function deletionFilters(): array + { + return $this->deletionFiltersValue()->get(); + } + + /** + * @return Value> + */ + private function deletionFiltersValue(): Value + { + /** @var array $none */ + $none = []; + + return $this->deletionFilters ??= new Value($none); + } /** * Find Documents diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index fa5837674..c06cfb7e3 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -1727,24 +1727,25 @@ public function deleteDocument(Document $collection, string $id): bool } /** + * Deletes only the rows that match every one of the deletionFilters(). + * * @param array $sequences * @param array $permissionIds - * @param array $queries */ #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { throw new NotFoundException('Collection not found'); } - $seqSet = []; - foreach ($sequences as $seq) { - $seqSet[(string) $seq] = true; + $sequenceSet = []; + foreach ($sequences as $sequence) { + $sequenceSet[(string) $sequence] = true; } - $filters = Query::groupByType($queries)->filters; + $filters = Query::groupByType($this->deletionFilters())->filters; $count = 0; $deletedIds = []; $keptIds = []; @@ -1757,7 +1758,7 @@ public function deleteDocuments(Document $collection, array $sequences, array $p } $rowId = $row[Storage::SEQUENCE] ?? ''; $rowUid = $row[Storage::UID] ?? $docKey; - if (isset($seqSet[\is_scalar($rowId) ? (string) $rowId : ''])) { + if (isset($sequenceSet[\is_scalar($rowId) ? (string) $rowId : ''])) { if (! $this->matchesAll($row, $filters)) { $keptIds[\is_scalar($rowUid) ? (string) $rowUid : $docKey] = true; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 88a523503..3154d02fe 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2290,14 +2290,15 @@ public function deleteDocument(Document $collection, string $id): bool } /** + * Deletes only the rows that match every one of the deletionFilters(). + * * @param array $sequences * @param array $permissionIds - * @param array $queries * * @throws DatabaseException */ #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { $collectionId = $collection->getId(); $name = $this->getNamespace().'_'.$this->filter($collectionId); @@ -2308,7 +2309,7 @@ public function deleteDocuments(Document $collection, array $sequences, array $p $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - $queries = \array_map(static fn (Query $query): Query => clone $query, $queries); + $queries = \array_map(static fn (Query $query): Query => clone $query, $this->deletionFilters()); $this->escapeQueryAttributes($collection, $queries); $matching = $this->buildFilters($queries); if ($matching !== []) { diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index cf5ee7ae9..247105634 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -865,7 +865,15 @@ public function deleteDocument(Document $collection, string $id): bool } #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + { + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; + } + + #[\Override] + public function deleteDocumentsMatching(Document $collection, array $sequences, array $permissionIds, array $queries): int { /** @var int $result */ $result = $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index dc08c2c76..6b98bdeff 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -1393,12 +1393,13 @@ public function deleteDocument(Document $collection, string $id): bool } /** + * Deletes only the rows that match every one of the deletionFilters(). + * * @param array $sequences * @param array $permissionIds - * @param array $queries */ #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { if (empty($sequences) && empty($permissionIds)) { return 0; @@ -1406,6 +1407,7 @@ public function deleteDocuments(Document $collection, array $sequences, array $p $collectionId = $this->filter($collection->getId()); $idxKey = $this->idxKey($collectionId); + $queries = $this->deletionFilters(); return $this->transaction(function (RedisClient $redis) use ($collectionId, $sequences, $permissionIds, $queries, $idxKey): int { $sequenceSet = []; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 9f778849c..3900086c9 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1257,14 +1257,15 @@ public function upsertDocuments(Document $collection, array $changes, ?string $i } /** + * Deletes only the rows that match every one of the deletionFilters(). + * * @param array $sequences * @param array $permissionIds - * @param array $queries * * @throws DatabaseException */ #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { if (empty($sequences)) { return 0; @@ -1275,13 +1276,15 @@ public function deleteDocuments(Document $collection, array $sequences, array $p try { $name = $this->filter($collection->getId()); $filters = \array_values(\array_filter( - $this->mainRowQueries($queries, $collection), + $this->mainRowQueries($this->deletionFilters(), $collection), static fn (Query $query): bool => ! $query->getMethod()->isVector(), )); $builder = $this->newBuilder($name); $builder->filter([BaseQuery::equal(Storage::SEQUENCE, \array_values($sequences))]); - $this->applyFilters($builder, $filters, $name, $this->unqualifiedTable($name)); + if ($filters !== []) { + $this->applyFilters($builder, $filters, $name, $this->unqualifiedTable($name)); + } $result = $builder->delete(); $statement = $this->executeResult($result, Event::DocumentsDelete); @@ -3553,7 +3556,8 @@ protected function writeContext(array $skipPermissions = []): WriteContext * Does NOT call execute() - the caller is responsible for that. * * Floats are bound as 7.x bound them: a find() binds them in fixed-point notation, every other statement as - * PHP writes them, so a write keeps a magnitude fixed point would round to zero. + * PHP writes them, so a write keeps a magnitude fixed point would round to zero. A bulk delete binds them as a + * find() does, so its filters match the rows the find() of the documents it deletes matched. * * @param string $collection The collection the statement reads or writes, for the profiler * @return PDOStatement|DatabasePDOStatement|PDOStatementProxy @@ -3566,7 +3570,7 @@ protected function executeResult(Statement $result, ?Event $event = null, string if (\is_bool($value) && $this->supports(Capability::IntegerBooleans)) { $value = (int) $value; } - if (\is_float($value) && $event === Event::DocumentFind) { + if (\is_float($value) && ($event === Event::DocumentFind || $event === Event::DocumentsDelete)) { $prepared->bindValue($i + 1, $this->getFloatPrecision($value), PDO::PARAM_STR); } elseif (\is_float($value)) { $prepared->bindValue($i + 1, $value, PDO::PARAM_STR); diff --git a/src/Database/Exception/Contention.php b/src/Database/Exception/Contention.php index 5e0dd347b..8897dd66e 100644 --- a/src/Database/Exception/Contention.php +++ b/src/Database/Exception/Contention.php @@ -6,6 +6,10 @@ * Thrown when the engine aborts a statement over a lock conflict with a concurrent transaction: a deadlock, a lock * wait timeout, a serialization failure, a lock that is not available or a busy database. The engine may have rolled * the whole transaction back with it, so nothing the transaction wrote is stored and running it again can succeed. + * + * The library throws it too when a concurrent write changes what a transaction read before the transaction writes + * it, as when deleteDocuments() deletes fewer documents of a batch than still matched its filters; the transaction + * is rolled back, and running it again can succeed. */ class Contention extends Transaction { diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 296ca7aef..cd065f94d 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -3060,7 +3060,15 @@ private function dispatchDeleted(string $collection, Document $document, array $ * * Each batch deletes only the documents that still match the filters when it is deleted: a document another write * changed after it was found, so that it no longer matches, is kept, and is neither counted, handed to $onNext nor - * cascaded. A limit counts the documents found, kept ones included. + * cascaded. A limit counts the documents found, kept ones included. Without a transaction, as on a standalone + * MongoDB server, a document changed between the check and the delete is kept the same way, but its cascade may + * already have run. + * + * Each batch commits on its own, so a batch that fails leaves the batches before it deleted; they were handed to + * $onNext and Event::DocumentsDelete reports them when the failure is a contention. In a transaction begun with + * startTransaction(), a batch that fails with a contention is retried in its savepoint, which on MySQL and MariaDB + * reads the transaction's snapshot again and fails the same way, so roll the whole transaction back and run it + * again. * * @param string $collection The collection identifier * @param array $queries Queries to filter documents for deletion @@ -3076,7 +3084,8 @@ private function dispatchDeleted(string $collection, Document $document, array $ * @throws LimitException When $batchSize is above BATCH_SIZE * @throws QueryException * @throws RestrictedException - * @throws ContentionException When documents keep changing between the check and the delete of a batch + * @throws ContentionException When documents keep changing between the check and the delete of a batch, in a + * transaction * @throws Throwable */ public function deleteDocuments( @@ -3155,68 +3164,15 @@ public function deleteDocuments( } $cacheTarget = $collection->getId() === self::METADATA ? $batch : $collection->getId(); - $deleted = $this->withMutation(Event::DocumentsDelete, $cacheTarget, function () use ($collection, $batch, $parsed): array { - $filters = []; - $documents = $parsed->filters === [] ? $batch : $this->stillMatching($collection, $batch, $parsed, $filters); - - $sequences = []; - $permissionIds = []; - - foreach ($documents as $document) { - $seq = $document->getSequence(); - if ($seq !== null) { - $sequences[] = $seq; - } - if (! empty($document->getPermissions())) { - $permissionIds[] = $document->getId(); - } - - if ($this->relationshipHook?->isEnabled()) { - $this->silent(fn () => $this->relationshipHook->beforeDocumentDelete( - $collection, - $document - )); - } - - // Check if document was updated after the request timestamp - try { - $oldUpdatedAt = new PhpDateTime($document->getUpdatedAt() ?? 'now'); - } catch (Exception $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } - - $requestTimestamp = $this->requestTimestamp()->get(); - if ($requestTimestamp !== null && $oldUpdatedAt > $requestTimestamp) { - throw new ConflictException('Document was updated after the request timestamp'); - } - } - - if ($documents === []) { - return []; - } - - $count = $this->adapter->deleteDocuments( - $collection, - $sequences, - $permissionIds, - $filters, - ); - - if ($filters !== [] && $count !== \count($sequences)) { - throw new ContentionException('Documents were changed while they were being deleted'); - } - - foreach ($documents as $document) { - $this->withDocumentTenant( - $document, - fn () => $this->advanceCollectionCacheEpoch($collection->getId(), $document->getId()) - ); + try { + $deleted = $this->withMutation(Event::DocumentsDelete, $cacheTarget, fn (): array => $this->deleteBatch($collection, $batch, $parsed)); + } catch (ContentionException $error) { + if ($modified > 0 && ! $this->adapter->inTransaction()) { + $this->announceBatchDeleted($collection, $modified); } - $this->queueDocumentPurges($collection->getId(), $documents); - - return $documents; - }); + throw $error; + } foreach ($deleted as $document) { if ($onNext !== null) { @@ -3236,12 +3192,157 @@ public function deleteDocuments( $last = \end($batch); } + $this->announceBatchDeleted($collection, $modified); + + return $modified; + } + + private function announceBatchDeleted(Collection $collection, int $modified): void + { $listeners = $this->listens(Event::DocumentsDelete); if ($listeners !== []) { $this->dispatch(new Event\Document\BatchDeleted($collection->getId(), $modified), $listeners); } + } - return $modified; + /** + * Delete the documents of a batch deleteDocuments() found that still match its filters, in the transaction the + * caller runs it in, and return them. + * + * A document whose filters are all on its immutable sequence still matches, so such a batch is not read again. + * When the adapter deletes fewer rows than it was handed, a document changed between the read and the delete: in a + * transaction the batch fails with a contention the transaction retries, and without one, as on a standalone + * MongoDB server, the documents still stored are left out. A cascade then has already run for them, so without a + * transaction cascades are best-effort. + * + * @param array $batch + * @return array + * + * @throws ConflictException + * @throws ContentionException + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + private function deleteBatch(Collection $collection, array $batch, ParsedQuery $parsed): array + { + $filters = []; + $documents = self::onSequenceOnly($parsed->filters) ? $batch : $this->stillMatching($collection, $batch, $parsed, $filters); + + $sequences = []; + $permissionIds = []; + + foreach ($documents as $document) { + $sequence = $document->getSequence(); + if ($sequence !== null) { + $sequences[] = $sequence; + } + if (! empty($document->getPermissions())) { + $permissionIds[] = $document->getId(); + } + + if ($this->relationshipHook?->isEnabled()) { + $this->silent(fn () => $this->relationshipHook->beforeDocumentDelete( + $collection, + $document + )); + } + + try { + $oldUpdatedAt = new PhpDateTime($document->getUpdatedAt() ?? 'now'); + } catch (Exception $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + $requestTimestamp = $this->requestTimestamp()->get(); + if ($requestTimestamp !== null && $oldUpdatedAt > $requestTimestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + } + + if ($documents === []) { + return []; + } + + if ($filters === []) { + $this->adapter->deleteDocuments($collection, $sequences, $permissionIds); + } elseif ($this->adapter->deleteDocumentsMatching($collection, $sequences, $permissionIds, $filters) !== \count($sequences)) { + if ($this->adapter->inTransaction()) { + throw new ContentionException('Documents were changed while they were being deleted'); + } + + $documents = $this->withoutStored($collection, $documents); + } + + foreach ($documents as $document) { + $this->withDocumentTenant( + $document, + fn () => $this->advanceCollectionCacheEpoch($collection->getId(), $document->getId()) + ); + } + + $this->queueDocumentPurges($collection->getId(), $documents); + + return $documents; + } + + /** + * Whether every filter, and every filter nested in one, is on the sequence, which no write changes. + * + * @param array $filters + */ + private static function onSequenceOnly(array $filters): bool + { + foreach ($filters as $filter) { + if ($filter->isNested()) { + /** @var array $nested */ + $nested = $filter->getValues(); + if (! self::onSequenceOnly($nested)) { + return false; + } + } elseif ($filter->getAttribute() !== Document::SEQUENCE) { + return false; + } + } + + return true; + } + + /** + * The documents given that are no longer stored, whatever their permissions. + * + * @param array $documents + * @return list + * + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + private function withoutStored(Collection $collection, array $documents): array + { + $sequences = []; + foreach ($documents as $document) { + $sequences[] = (string) $document->getSequence(); + } + + $queries = [Query::equal(Document::SEQUENCE, $sequences), Query::limit(\count($sequences))]; + + /** @var array $stored */ + $stored = $this->silent(fn () => $this->skipQueryHooks(fn () => $this->skipValidation(fn () => $this->skipRelationships( + fn () => $this->authorization->skip(fn () => $this->fetch($collection, $queries, PermissionType::Read, false)) + )))); + + $storedSequences = []; + foreach ($stored as $document) { + $storedSequences[(string) $document->getSequence()] = true; + } + + return \array_values(\array_filter( + $documents, + static fn (Document $document): bool => ! isset($storedSequences[(string) $document->getSequence()]), + )); } /** diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index e632b7d6a..afa5ffec6 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -8316,7 +8316,7 @@ public function testDeleteBulkDocumentsKeepsDocumentsThatStoppedMatching(): void $ids[] = $document->getId(); } - $deleted = $database->getAdapter()->deleteDocuments($collection, $sequences, $ids, $database->convertQueries($collection, [$filter])); + $deleted = $database->getAdapter()->deleteDocumentsMatching($collection, $sequences, $ids, $database->convertQueries($collection, [$filter])); $this->assertSame(2, $deleted); $remaining = \array_map(static fn (Document $document): string => $document->getId(), $database->find($collectionId, [Query::orderAsc('$id')])); diff --git a/tests/unit/Documents/DeleteDocumentsRaceTest.php b/tests/unit/Documents/DeleteDocumentsRaceTest.php index 62429b955..4715f52c1 100644 --- a/tests/unit/Documents/DeleteDocumentsRaceTest.php +++ b/tests/unit/Documents/DeleteDocumentsRaceTest.php @@ -170,7 +170,7 @@ public function testTheAdapterDeletesOnlyTheRowsThatStillMatchAndKeepsTheirPermi $sequences = \array_values(\array_filter(\array_map(static fn (Document $document): ?string => $document->getSequence(), $found))); $permissionIds = \array_map(static fn (Document $document): string => $document->getId(), $found); - $count = $engine->deleteDocuments($collection, $sequences, $permissionIds, $database->convertQueries($collection, [$this->expired()])); + $count = $engine->deleteDocumentsMatching($collection, $sequences, $permissionIds, $database->convertQueries($collection, [$this->expired()])); $this->assertSame(2, $count); $this->assertSame(['b', 'd'], $this->stored($database)); diff --git a/tests/unit/Documents/Interleaving/Interleaves.php b/tests/unit/Documents/Interleaving/Interleaves.php index 4afa4b8c5..c564b20a4 100644 --- a/tests/unit/Documents/Interleaving/Interleaves.php +++ b/tests/unit/Documents/Interleaving/Interleaves.php @@ -9,7 +9,7 @@ /** * Runs a write of the test's choosing right after a find() returns, or right before a bulk delete reaches the - * engine, as another connection whose write lands in between would. + * engine, as another connection whose write lands in between would, and counts the finds and bulk deletes. */ trait Interleaves { @@ -23,11 +23,15 @@ trait Interleaves */ public ?Closure $beforeDelete = null; + public int $finds = 0; + public int $deletes = 0; #[\Override] public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], CursorDirection $cursorDirection = CursorDirection::After, PermissionType $forPermission = PermissionType::Read): array { + $this->finds++; + $documents = parent::find($collection, $queries, $limit, $offset, $orderAttributes, $orderTypes, $cursor, $cursorDirection, $forPermission); if ($this->afterFind !== null) { @@ -38,7 +42,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 } #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { $this->deletes++; @@ -46,6 +50,6 @@ public function deleteDocuments(Document $collection, array $sequences, array $p ($this->beforeDelete)($collection); } - return parent::deleteDocuments($collection, $sequences, $permissionIds, $queries); + return parent::deleteDocuments($collection, $sequences, $permissionIds); } } diff --git a/tests/unit/MirrorTest.php b/tests/unit/MirrorTest.php index c213a76af..0206e3479 100644 --- a/tests/unit/MirrorTest.php +++ b/tests/unit/MirrorTest.php @@ -1807,11 +1807,11 @@ public function deleteDocument(Document $collection, string $id): bool } #[\Override] - public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { $this->reach(); - return parent::deleteDocuments($collection, $sequences, $permissionIds, $queries); + return parent::deleteDocuments($collection, $sequences, $permissionIds); } private function reach(): void From b4592b8d4fd36565a536f180eca2009f8656bc06 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 18:07:17 +1300 Subject: [PATCH 06/11] test(documents): cover float filters, deletes without transactions and retries A float filter bound differently by the find and the delete failed every batch with a contention, and an engine without transactions threw one after deletes it could not undo; both now fail on the previous head and pass. The rest pin that a batch rolled back and retried reports its documents once, that a contention in a later batch still reports the batches deleted, that a delete without filters or filtered only on $sequence reads each batch once, and that a full deleteDocuments() with a float filter deletes what find() matched on every e2e adapter. The assertion that the interleaved write was rolled back is dropped: it holds only because the test runs that write on the deleting connection. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 40 +++++ .../Documents/DeleteDocumentsRaceTest.php | 152 +++++++++++++++++- tests/unit/Documents/Interleaving/Memory.php | 22 +++ .../Documents/Interleaving/Standalone.php | 38 +++++ 4 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 tests/unit/Documents/Interleaving/Standalone.php diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index afa5ffec6..888f8b099 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -8326,6 +8326,46 @@ public function testDeleteBulkDocumentsKeepsDocumentsThatStoppedMatching(): void $database->deleteCollection($collectionId); } + public function testDeleteBulkDocumentsWithAFloatFilterDeletesWhatTheFindMatched(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $collectionId = 'bulk_delete_float_filter'; + $filter = Query::greaterThan('score', 1.0E-20); + + $database->createCollection(Collection::create(id: $collectionId, attributes: [ + Attribute::float(key: 'score', required: true), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()), + ], documentSecurity: false)); + + foreach (['tiny' => 1.0E-21, 'large' => 1.5, 'negative' => -1.0] as $id => $score) { + $database->createDocument($collectionId, new Document([ + '$id' => $id, + '$permissions' => [Permission::read(Role::any())], + 'score' => $score, + ])); + } + + $found = \array_map(static fn (Document $document): string => $document->getId(), $database->find($collectionId, [$filter])); + $deleted = []; + $count = $database->deleteDocuments($collectionId, [$filter], onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + }); + + \sort($found); + \sort($deleted); + $this->assertSame(\count($found), $count); + $this->assertSame($found, $deleted); + $remaining = \array_map(static fn (Document $document): string => $document->getId(), $database->find($collectionId)); + $this->assertSame([], \array_values(\array_intersect($found, $remaining))); + $this->assertContains('negative', $remaining); + + $database->deleteCollection($collectionId); + } + public function testDeleteBulkDocumentsWithCallbackSupport(): void { /** @var Database $database */ diff --git a/tests/unit/Documents/DeleteDocumentsRaceTest.php b/tests/unit/Documents/DeleteDocumentsRaceTest.php index 4715f52c1..c051bc380 100644 --- a/tests/unit/Documents/DeleteDocumentsRaceTest.php +++ b/tests/unit/Documents/DeleteDocumentsRaceTest.php @@ -8,13 +8,17 @@ use Tests\Unit\Documents\Interleaving\Memory; use Tests\Unit\Documents\Interleaving\Refresh; use Tests\Unit\Documents\Interleaving\SQLite; +use Tests\Unit\Documents\Interleaving\Standalone; use Utopia\Cache\Adapter\None; use Utopia\Cache\Cache; use Utopia\Database\Attribute; use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Event\Document\BatchDeleted; +use Utopia\Database\Event\Domain; use Utopia\Database\Exception\Contention as ContentionException; +use Utopia\Database\Hook\Lifecycle; use Utopia\Database\Hook\Permissions; use Utopia\Database\Hook\Relationships; use Utopia\Database\Permission; @@ -152,7 +156,122 @@ public function testADocumentChangedRightBeforeTheDeleteRollsTheBatchBack(Closur $this->assertSame(3, $engine->deletes, 'The batch is tried again before it fails'); $this->assertSame(['a', 'b', 'c'], $this->stored($database), 'Nothing of the failed batch is stored'); - $this->assertSame(self::EXPIRED, $this->expiry($database, 'b')); + } + + public function testADocumentChangedRightBeforeTheDeleteIsKeptWhenTheRetryReadsItAgain(): void + { + $engine = new Memory(); + [$database] = $this->database($engine); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED]); + $engine->beforeDelete = function (Document $collection) use ($database, $engine): void { + if ($collection->getId() === self::COLLECTION && $engine->deletes === 1) { + $engine->detached(fn (): Document => $database->updateDocument(self::COLLECTION, 'b', new Document(['expiresAt' => self::REFRESHED]))); + } + }; + + $deleted = []; + $count = $database->deleteDocuments(self::COLLECTION, [$this->expired()], onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + }); + + $this->assertSame(2, $engine->deletes, 'The batch is rolled back once and deleted on the retry'); + $this->assertSame(2, $count, 'Only the retry that committed is counted'); + $this->assertSame(['a', 'c'], $deleted, 'Each deleted document is handed to $onNext once'); + $this->assertSame(['b'], $this->stored($database)); + $this->assertSame(self::REFRESHED, $this->expiry($database, 'b')); + } + + public function testAContentionInALaterBatchReportsTheBatchesAlreadyDeleted(): void + { + [$database, $engine] = $this->database(new Memory()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED, 'd' => self::EXPIRED]); + $engine->beforeDelete = function (Document $collection) use ($database, $engine): void { + if ($collection->getId() === self::COLLECTION && $engine->deletes > 1) { + $database->updateDocument(self::COLLECTION, 'd', new Document(['expiresAt' => self::REFRESHED])); + } + }; + $events = $this->batchEvents($database); + + $deleted = []; + try { + $database->deleteDocuments(self::COLLECTION, [$this->expired()], batchSize: 2, onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + }); + $this->fail('The second batch must fail with a contention'); + } catch (ContentionException) { + } + + $this->assertSame(['a', 'b'], $deleted); + $this->assertSame(['c', 'd'], $this->stored($database), 'The first batch stays deleted'); + $this->assertSame([2], $events->counts, 'The deleted batch is reported'); + } + + public function testWithoutATransactionADocumentChangedRightBeforeTheDeleteIsKeptAndTheRestReported(): void + { + [$database, $engine] = $this->database(new Standalone()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED, 'd' => self::EXPIRED, 'e' => self::REFRESHED]); + $engine->beforeDelete = function (Document $collection) use ($database, $engine): void { + if ($collection->getId() === self::COLLECTION && $engine->deletes === 1) { + $database->updateDocument(self::COLLECTION, 'b', new Document(['expiresAt' => self::REFRESHED])); + } + }; + $events = $this->batchEvents($database); + + $deleted = []; + $count = $database->deleteDocuments(self::COLLECTION, [$this->expired()], batchSize: 2, onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + }); + + $this->assertSame(3, $count, 'The document kept is not counted, and the next batch is still deleted'); + $this->assertSame(['a', 'c', 'd'], $deleted, 'Every deleted document is handed to $onNext'); + $this->assertSame([3], $events->counts); + $this->assertSame(['b', 'e'], $this->stored($database)); + $this->assertSame(['b', 'e'], \array_map(static fn (Document $document): string => $document->getId(), $database->find(self::COLLECTION)), 'The kept document keeps its permission rows'); + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testAFloatFilterDeletesTheDocumentsTheFindMatched(Closure $adapter): void + { + [$database] = $this->database($adapter()); + $database->createCollection(Collection::create( + id: 'scores', + attributes: [Attribute::float(key: 'score')], + permissions: $this->permissions(), + documentSecurity: false, + )); + $database->createDocument('scores', new Document([Document::ID => 'tiny', 'score' => 1.0E-21])); + $database->createDocument('scores', new Document([Document::ID => 'none', 'score' => -1.0])); + $filter = Query::greaterThan('score', 1.0E-20); + + $found = \array_map(static fn (Document $document): string => $document->getId(), $database->find('scores', [$filter])); + $count = $database->deleteDocuments('scores', [$filter]); + + $this->assertSame(\count($found), $count, 'The delete binds a float as the find does'); + $remaining = \array_map(static fn (Document $document): string => $document->getId(), $database->find('scores')); + $this->assertSame([], \array_values(\array_intersect($found, $remaining))); + $this->assertContains('none', $remaining); + } + + /** + * @param Closure(): (Memory|SQLite) $adapter + */ + #[DataProvider('adapters')] + public function testABatchFilteredOnlyOnTheSequenceIsNotReadAgain(Closure $adapter): void + { + [$database, $engine] = $this->database($adapter()); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::REFRESHED, 'c' => self::EXPIRED]); + $sequences = $database->getAuthorization()->skip(fn (): array => \array_map( + static fn (Document $document): string => (string) $document->getSequence(), + $database->find(self::COLLECTION, [Query::equal(Document::ID, ['a', 'c'])]), + )); + $finds = $engine->finds; + + $this->assertSame(2, $database->deleteDocuments(self::COLLECTION, [Query::equal(Document::SEQUENCE, $sequences)], batchSize: 2)); + $this->assertSame(2, $engine->finds - $finds, 'Each batch is found once and not read again'); + $this->assertSame(['b'], $this->stored($database)); } /** @@ -212,16 +331,21 @@ public function testADeleteWithoutFiltersDeletesEveryDocument(Closure $adapter): { [$database, $engine] = $this->database($adapter()); $this->seed($database, ['a' => self::EXPIRED, 'b' => self::REFRESHED, 'c' => self::EXPIRED]); + $finds = $engine->finds; $this->assertSame(3, $database->deleteDocuments(self::COLLECTION, batchSize: 2)); + $this->assertSame(2, $engine->finds - $finds, 'Each batch is found once and not read again'); $this->assertSame([], $this->stored($database)); $this->assertSame(2, $engine->deletes); } /** - * @return array{Database, Memory|SQLite} + * @template T of Memory|SQLite|Standalone + * + * @param T $adapter + * @return array{Database, T} */ - private function database(Memory|SQLite $adapter): array + private function database(Memory|SQLite|Standalone $adapter): array { $authorization = new Authorization(); $authorization->addRole(self::OWNER); @@ -245,6 +369,28 @@ private function database(Memory|SQLite $adapter): array return [$database, $adapter]; } + /** + * @return object{counts: list} + */ + private function batchEvents(Database $database): object + { + $events = new class () implements Lifecycle { + /** @var list */ + public array $counts = []; + + #[\Override] + public function handle(Domain $event): void + { + if ($event instanceof BatchDeleted) { + $this->counts[] = $event->count; + } + } + }; + $database->addHook($events); + + return $events; + } + /** * @return list */ diff --git a/tests/unit/Documents/Interleaving/Memory.php b/tests/unit/Documents/Interleaving/Memory.php index 4d7409c1c..b9cfb1115 100644 --- a/tests/unit/Documents/Interleaving/Memory.php +++ b/tests/unit/Documents/Interleaving/Memory.php @@ -2,9 +2,31 @@ namespace Tests\Unit\Documents\Interleaving; +use Closure; use Utopia\Database\Adapter\Memory as MemoryAdapter; final class Memory extends MemoryAdapter { use Interleaves; + + /** + * Run a write outside the open transaction, as another connection would, so that rolling the transaction back + * keeps it. + * + * @param Closure(): mixed $write + */ + public function detached(Closure $write): void + { + $depth = $this->inTransaction; + $journals = $this->journals; + $this->inTransaction = 0; + $this->journals = []; + + try { + $write(); + } finally { + $this->inTransaction = $depth; + $this->journals = $journals; + } + } } diff --git a/tests/unit/Documents/Interleaving/Standalone.php b/tests/unit/Documents/Interleaving/Standalone.php new file mode 100644 index 000000000..403426e7a --- /dev/null +++ b/tests/unit/Documents/Interleaving/Standalone.php @@ -0,0 +1,38 @@ + Date: Thu, 15 Oct 2026 18:07:17 +1300 Subject: [PATCH 07/11] docs: describe the 8.1.4 deleteDocuments fix as non-breaking The adapter signature no longer changes, so custom adapters need no code change; the new deleteDocumentsMatching() is listed under Added and the deletionFilters() opt-in, the non-transactional behaviour and the retry advice for manual transactions are documented. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 38 ++++++++++++++++++++++++++------------ UPGRADE.md | 26 +++++++++++++------------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a086c2554..3861854de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,26 +2,40 @@ ## 8.1.4 -8.1.4 fixes `deleteDocuments()` deleting documents that stopped matching its queries after they were found. Custom -adapters that implement `Adapter::deleteDocuments()` add a parameter; see +8.1.4 fixes `deleteDocuments()` deleting documents that stopped matching its queries after they were found. It needs +no code change; a custom adapter can opt in to the fix, see [Upgrading from 8.1.3 to 8.1.4](UPGRADE.md#upgrading-from-813-to-814). +### Added + +- `Adapter::deleteDocumentsMatching(Document $collection, array $sequences, array $permissionIds, array $queries): int` + deletes the rows of `$sequences` that match every filter query in `$queries`, as `find()` is handed them, removes + the permission rows of `$permissionIds` but those of a row kept because it no longer matches, and returns the + number of rows deleted. It calls `deleteDocuments()` with the queries readable through the new protected + `Adapter::deletionFilters()`, so an override of `deleteDocuments()` still runs. Memory, SQLite, MariaDB, MySQL, + PostgreSQL, MongoDB and Redis delete only the rows that match them; `Pool` and `ReadWritePool` pass the call on. A + custom adapter that ignores them deletes every row of `$sequences`, as before. + ### Fixed - `deleteDocuments()` deletes only the documents that still match its filters when each batch is deleted. It found a batch and then deleted it by internal id alone, so a document another write changed in between, so that it no longer matched (such as a presence refreshed before an expired-presence cleanup deleted it), was deleted anyway, cascaded to its related documents, lost its permission rows, and was counted and handed to `onNext`. Each batch - with filters now reads its documents again, with the same queries, in the transaction that deletes them, and the - adapter deletes only the rows that match the filters there. A document that no longer matches is kept with its - related documents and permission rows, and is neither counted, handed to `onNext` nor in `Event::DocumentsDelete`'s - count. A limit counts the documents found, kept ones included, so a kept document is not replaced by a later one. - When a document changes between that read and the delete, the batch is rolled back and tried again, and fails with - `Exception\Contention` once the transaction's retries are spent. A delete without filters runs as before. -- `Adapter::deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int` - takes the filter queries every deleted row must match, as `find()` is handed them, and returns the number of rows - deleted. Memory, SQLite, MariaDB, MySQL, PostgreSQL, MongoDB and Redis apply them in the delete itself; `Pool` and - `ReadWritePool` pass them on. A row kept because it no longer matches keeps its permission rows. + with filters on anything but `$sequence` now reads its documents again, with the same queries, in the transaction + that deletes them, and deletes them with `Adapter::deleteDocumentsMatching()`. A document that no longer matches is + kept with its related documents and permission rows, and is neither counted, handed to `onNext` nor in + `Event::DocumentsDelete`'s count. A limit counts the documents found, kept ones included, so a kept document is not + replaced by a later one. When a document changes between that read and the delete, the batch is rolled back and + tried again, and fails with `Exception\Contention` once the transaction's retries are spent; the batches before it + stay deleted and `Event::DocumentsDelete` reports them. In a transaction begun with `startTransaction()` the retry + runs in a savepoint, which on MySQL and MariaDB reads the transaction's snapshot again and fails the same way, so the + caller rolls the whole transaction back and runs it again. Without transactions, as on a standalone MongoDB server, + nothing is rolled back: the documents still stored are kept and left out of the count, `onNext`, the cache purges + and the event, the remaining batches are deleted, and a cascade that already ran for a kept document stays, so + cascades are best-effort there. A delete without filters, or with filters only on `$sequence`, runs as before. +- A bulk delete on SQLite, MariaDB, MySQL and PostgreSQL binds the floats of its filters in fixed-point notation, as + `find()` binds them, so it matches the rows the find matched. ## 8.1.3 diff --git a/UPGRADE.md b/UPGRADE.md index 9cae244d5..80a148fd6 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,18 +1,18 @@ # Upgrading from 8.1.3 to 8.1.4 -8.1.4 is a patch release. `deleteDocuments()` keeps its signature and now deletes only the documents that still match -its filters when they are deleted; see [CHANGELOG.md](CHANGELOG.md#814). - -A custom adapter that extends `Adapter` and implements `deleteDocuments()`, or a subclass of a bundled adapter or -`Pool` that overrides it, adds the new last parameter, as PHP refuses an override that drops it: - -```php -public function deleteDocuments(Document $collection, array $sequences, array $permissionIds, array $queries = []): int -``` - -Delete only the rows of `$sequences` that match every query in `$queries` (filter queries, as `find()` is handed them), -remove the permission rows of `$permissionIds` but those of a row kept because it no longer matches, and return the -number of rows deleted. An override that delegates passes `$queries` on to its parent. +8.1.4 is a patch release and needs no code change. `Database::deleteDocuments()` keeps its signature and now deletes +only the documents that still match its filters when they are deleted; see [CHANGELOG.md](CHANGELOG.md#814). + +`Adapter::deleteDocuments()` keeps its signature. A batch with filters reaches the adapter through the new +`Adapter::deleteDocumentsMatching()`, which calls `deleteDocuments()` with the filters readable through +`$this->deletionFilters()`, so an override of `deleteDocuments()` in a subclass of a bundled adapter still runs and, +when it calls its parent, deletes only the rows that match. A custom adapter that extends `Adapter` directly deletes +every row it is handed, as in 8.1.3; to keep a document another write changed after it was found, delete only the +rows that match every query of `deletionFilters()` (filter queries, as `find()` is handed them), keep the permission +rows of a row kept, and return the number of rows deleted. + +`deleteDocuments()` can now throw `Exception\Contention` when documents keep changing between the check and the delete +of a batch. Inside a transaction begun with `startTransaction()`, roll it back and run it again. # Upgrading from 8.1.2 to 8.1.3 From e2f90c8f2fbee768b1db6a08e9b14acf46ecd8f4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 18:40:48 +1300 Subject: [PATCH 08/11] fix(documents): run pool overrides and trust only stored rows on short deletes Pool overrode deleteDocumentsMatching() and sent it straight to the pooled adapter, so Pool::deleteDocuments() and any subclass override of it never ran for a filtered batch. Pool now reads the filters scoped to it in deleteDocuments() and hands them to the pooled adapter's deleteDocumentsMatching() there. Any count other than the rows handed was taken as a race, so a custom adapter that ignores the filters and counts differently (none, or its permission rows too) failed every filtered delete with a contention and no concurrent write. The count is now a hint: a short count makes the batch read which documents are still stored, and only one still stored is a race; a higher count is ignored. The return contract is documented on Adapter::deleteDocuments(). The read of the documents still stored uses the delete permission, which the query cache never serves, so it cannot see a result cached before the purge. MongoDB maps internal keys once over the whole delete filter, as find() does. The docblock says a document another deleter removed is counted without a transaction, and that a MongoDB replica set has no savepoint to retry a batch in under a manual transaction. Co-Authored-By: Claude Opus 5.5 --- src/Database/Adapter.php | 3 +++ src/Database/Adapter/Memory.php | 26 +++++++++++----------- src/Database/Adapter/Mongo.php | 4 ++-- src/Database/Adapter/Pool.php | 16 +++++++------- src/Database/Adapter/Redis.php | 26 +++++++++++----------- src/Database/Adapter/SQL.php | 2 -- src/Database/Trait/Documents.php | 37 +++++++++++++++++--------------- 7 files changed, 59 insertions(+), 55 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 22525c2c9..612d4507f 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -776,6 +776,9 @@ abstract public function deleteDocument(Document $collection, string $id): bool; /** * @param array $sequences * @param array $permissionIds + * @return int The number of document rows deleted. Database::deleteDocuments() takes it as a hint: a count below + * the sequences given makes it check which of the documents are still stored, and a higher one is + * ignored. */ abstract public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int; diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index c06cfb7e3..792cfe8bb 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -1749,7 +1749,7 @@ public function deleteDocuments(Document $collection, array $sequences, array $p $count = 0; $deletedIds = []; $keptIds = []; - foreach ($this->data[$key]['documents'] as $docKey => $row) { + foreach ($this->data[$key]['documents'] as $storageKey => $row) { // With sharedTables the row map is keyed by "tenant|uid" so sequence // collisions across tenants are possible. Skip rows that don't belong // to the current tenant so we never delete another tenant's data. @@ -1757,24 +1757,24 @@ public function deleteDocuments(Document $collection, array $sequences, array $p continue; } $rowId = $row[Storage::SEQUENCE] ?? ''; - $rowUid = $row[Storage::UID] ?? $docKey; + $rowUid = $row[Storage::UID] ?? $storageKey; if (isset($sequenceSet[\is_scalar($rowId) ? (string) $rowId : ''])) { if (! $this->matchesAll($row, $filters)) { - $keptIds[\is_scalar($rowUid) ? (string) $rowUid : $docKey] = true; + $keptIds[\is_scalar($rowUid) ? (string) $rowUid : $storageKey] = true; continue; } - $deletedIds[\is_scalar($rowUid) ? (string) $rowUid : $docKey] = true; + $deletedIds[\is_scalar($rowUid) ? (string) $rowUid : $storageKey] = true; $oldSignatures = $this->rowUniqueSignatures($key, $row); - unset($this->data[$key]['documents'][$docKey]); - $this->journal(function () use ($key, $docKey, $row): void { - $this->data[$key]['documents'][$docKey] = $row; + unset($this->data[$key]['documents'][$storageKey]); + $this->journal(function () use ($key, $storageKey, $row): void { + $this->data[$key]['documents'][$storageKey] = $row; }); foreach ($oldSignatures as $indexId => $hash) { - if (($this->uniqueIndexHashes[$key][$indexId][$hash] ?? null) === $docKey) { + if (($this->uniqueIndexHashes[$key][$indexId][$hash] ?? null) === $storageKey) { unset($this->uniqueIndexHashes[$key][$indexId][$hash]); - $this->journal(function () use ($key, $indexId, $hash, $docKey): void { - $this->uniqueIndexHashes[$key][$indexId][$hash] = $docKey; + $this->journal(function () use ($key, $indexId, $hash, $storageKey): void { + $this->uniqueIndexHashes[$key][$indexId][$hash] = $storageKey; }); } } @@ -1782,16 +1782,16 @@ public function deleteDocuments(Document $collection, array $sequences, array $p } } - $permSet = ! empty($permissionIds) + $permissionSet = ! empty($permissionIds) ? \array_flip(\array_map('strval', $permissionIds)) : []; - if (! empty($deletedIds) || ! empty($permSet)) { + if (! empty($deletedIds) || ! empty($permissionSet)) { $tenant = $this->getTenant(); foreach (\array_keys($deletedIds) as $documentId) { $this->removePermissionsForDocument($key, (string) $documentId, $tenant, $this->sharedTables); } - foreach (\array_keys($permSet) as $documentId) { + foreach (\array_keys($permissionSet) as $documentId) { if (isset($deletedIds[$documentId]) || isset($keptIds[$documentId])) { continue; } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 3154d02fe..687b9dd10 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2307,8 +2307,6 @@ public function deleteDocuments(Document $collection, array $sequences, array $p $filters = $this->buildFilters([new Query(Method::Equal, Storage::SEQUENCE, $sequences)]); $filters = $this->applyTenantFilter($filters, $collectionId); - $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - $queries = \array_map(static fn (Query $query): Query => clone $query, $this->deletionFilters()); $this->escapeQueryAttributes($collection, $queries); $matching = $this->buildFilters($queries); @@ -2316,6 +2314,8 @@ public function deleteDocuments(Document $collection, array $sequences, array $p $filters = ['$and' => [$filters, $matching]]; } + $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); + $options = $this->getTransactionOptions(); try { diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 247105634..950ff23e2 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -864,19 +864,19 @@ public function deleteDocument(Document $collection, string $id): bool return $result; } + /** + * Hands the pooled adapter the deletionFilters() through its deleteDocumentsMatching(), as they are scoped to + * this pool, so that an override of this method still runs for a filtered batch. + */ #[\Override] public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int { - /** @var int $result */ - $result = $this->delegate(__FUNCTION__, \func_get_args()); - return $result; - } + $filters = $this->deletionFilters(); - #[\Override] - public function deleteDocumentsMatching(Document $collection, array $sequences, array $permissionIds, array $queries): int - { /** @var int $result */ - $result = $this->delegate(__FUNCTION__, \func_get_args()); + $result = $filters === [] + ? $this->delegate(__FUNCTION__, [$collection, $sequences, $permissionIds]) + : $this->delegate('deleteDocumentsMatching', [$collection, $sequences, $permissionIds, $filters]); return $result; } diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 6b98bdeff..f3d87e607 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -1406,26 +1406,26 @@ public function deleteDocuments(Document $collection, array $sequences, array $p } $collectionId = $this->filter($collection->getId()); - $idxKey = $this->idxKey($collectionId); + $indexKey = $this->idxKey($collectionId); $queries = $this->deletionFilters(); - return $this->transaction(function (RedisClient $redis) use ($collectionId, $sequences, $permissionIds, $queries, $idxKey): int { + return $this->transaction(function (RedisClient $redis) use ($collectionId, $sequences, $permissionIds, $queries, $indexKey): int { $sequenceSet = []; foreach ($sequences as $sequence) { $sequenceSet[(string) $sequence] = true; } - $allIds = $redis->sMembers($idxKey); + $allIds = $redis->sMembers($indexKey); if (! \is_array($allIds)) { $allIds = []; } - $docKeys = []; + $documentKeys = []; $redis->multi(\Redis::PIPELINE); foreach ($allIds as $id) { - $docKey = $this->docKey($collectionId, (string) $id); - $docKeys[(string) $id] = $docKey; - $redis->get($docKey); + $documentKey = $this->docKey($collectionId, (string) $id); + $documentKeys[(string) $id] = $documentKey; + $redis->get($documentKey); } $payloads = $redis->exec(); if (! \is_array($payloads)) { @@ -1449,21 +1449,21 @@ public function deleteDocuments(Document $collection, array $sequences, array $p continue; } - $deleted[$document->getId()] = ['payload' => $payload, 'docKey' => $docKeys[(string) $id]]; + $deleted[$document->getId()] = ['payload' => $payload, 'documentKey' => $documentKeys[(string) $id]]; } foreach ($deleted as $documentId => $deleteEntry) { - $deletedDocKey = $deleteEntry['docKey']; + $deletedKey = $deleteEntry['documentKey']; $this->journal('deleteDoc', [ 'collection' => $collectionId, 'id' => (string) $documentId, 'payload' => $deleteEntry['payload'], - 'docKey' => $deletedDocKey, - 'idxKey' => $idxKey, + 'docKey' => $deletedKey, + 'idxKey' => $indexKey, ]); $this->clearPermissions($collectionId, (string) $documentId); - $redis->del($deletedDocKey); - $redis->sRem($idxKey, \strtolower((string) $documentId)); + $redis->del($deletedKey); + $redis->sRem($indexKey, \strtolower((string) $documentId)); } foreach ($permissionIds as $permissionId) { diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 3900086c9..6780fe816 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1320,8 +1320,6 @@ private function unqualifiedTable(string $name): string } /** - * The document ids given whose row is no longer stored. - * * @param array $documentIds * @return array * diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index cd065f94d..88d4eb32d 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -3062,13 +3062,14 @@ private function dispatchDeleted(string $collection, Document $document, array $ * changed after it was found, so that it no longer matches, is kept, and is neither counted, handed to $onNext nor * cascaded. A limit counts the documents found, kept ones included. Without a transaction, as on a standalone * MongoDB server, a document changed between the check and the delete is kept the same way, but its cascade may - * already have run. + * already have run, and a document another deleter removed meanwhile is counted as deleted by this call. * * Each batch commits on its own, so a batch that fails leaves the batches before it deleted; they were handed to * $onNext and Event::DocumentsDelete reports them when the failure is a contention. In a transaction begun with * startTransaction(), a batch that fails with a contention is retried in its savepoint, which on MySQL and MariaDB - * reads the transaction's snapshot again and fails the same way, so roll the whole transaction back and run it - * again. + * reads the transaction's snapshot again and fails the same way; a MongoDB replica set has no savepoints, so the + * batch is not retried and its partial delete stays in the transaction. Either way, roll the whole transaction + * back and run it again. * * @param string $collection The collection identifier * @param array $queries Queries to filter documents for deletion @@ -3210,10 +3211,11 @@ private function announceBatchDeleted(Collection $collection, int $modified): vo * caller runs it in, and return them. * * A document whose filters are all on its immutable sequence still matches, so such a batch is not read again. - * When the adapter deletes fewer rows than it was handed, a document changed between the read and the delete: in a - * transaction the batch fails with a contention the transaction retries, and without one, as on a standalone - * MongoDB server, the documents still stored are left out. A cascade then has already run for them, so without a - * transaction cascades are best-effort. + * The adapter's count is only a hint: when it is short, the batch's documents still stored are read, and one that + * is still stored changed between the read and the delete. In a transaction the batch then fails with a + * contention the transaction retries; without one, as on a standalone MongoDB server, the documents still stored + * are left out, a document another deleter removed meanwhile is reported as deleted by this call, and a cascade + * that already ran for a kept document stays, so cascades are best-effort there. * * @param array $batch * @return array @@ -3251,8 +3253,8 @@ private function deleteBatch(Collection $collection, array $batch, ParsedQuery $ try { $oldUpdatedAt = new PhpDateTime($document->getUpdatedAt() ?? 'now'); - } catch (Exception $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } catch (Exception $error) { + throw new DatabaseException($error->getMessage(), $error->getCode(), $error); } $requestTimestamp = $this->requestTimestamp()->get(); @@ -3267,12 +3269,15 @@ private function deleteBatch(Collection $collection, array $batch, ParsedQuery $ if ($filters === []) { $this->adapter->deleteDocuments($collection, $sequences, $permissionIds); - } elseif ($this->adapter->deleteDocumentsMatching($collection, $sequences, $permissionIds, $filters) !== \count($sequences)) { - if ($this->adapter->inTransaction()) { - throw new ContentionException('Documents were changed while they were being deleted'); - } + } elseif ($this->adapter->deleteDocumentsMatching($collection, $sequences, $permissionIds, $filters) < \count($sequences)) { + $removed = $this->withoutStored($collection, $documents); + if (\count($removed) < \count($documents)) { + if ($this->adapter->inTransaction()) { + throw new ContentionException('Documents were changed while they were being deleted'); + } - $documents = $this->withoutStored($collection, $documents); + $documents = $removed; + } } foreach ($documents as $document) { @@ -3310,8 +3315,6 @@ private static function onSequenceOnly(array $filters): bool } /** - * The documents given that are no longer stored, whatever their permissions. - * * @param array $documents * @return list * @@ -3331,7 +3334,7 @@ private function withoutStored(Collection $collection, array $documents): array /** @var array $stored */ $stored = $this->silent(fn () => $this->skipQueryHooks(fn () => $this->skipValidation(fn () => $this->skipRelationships( - fn () => $this->authorization->skip(fn () => $this->fetch($collection, $queries, PermissionType::Read, false)) + fn () => $this->authorization->skip(fn () => $this->fetch($collection, $queries, PermissionType::Delete, false)) )))); $storedSequences = []; From 2ea68b373c85db91ee056979b877b8892525b864 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 18:40:48 +1300 Subject: [PATCH 09/11] test(documents): pin pool overrides, miscounting adapters and float finds A pool subclass wrapping deleteDocuments() ran zero times for a filtered batch, and an adapter reporting no rows or its permission rows too made the delete fail with a contention; both fail on the previous head. The float test now asserts what each engine finds, so the memory case is no longer vacuous, the e2e variant requires the document every engine finds, and the MongoDB delete is checked to name filter keys as find() does. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/Scopes/DocumentTests.php | 1 + .../Documents/DeleteDocumentsRaceTest.php | 83 +++++++++++++++++-- .../Documents/Interleaving/AuditingPool.php | 22 +++++ .../Documents/Interleaving/Miscounting.php | 27 ++++++ tests/unit/MongoQueryFilterTest.php | 28 +++++++ 5 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 tests/unit/Documents/Interleaving/AuditingPool.php create mode 100644 tests/unit/Documents/Interleaving/Miscounting.php diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 888f8b099..82d4ff312 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -8357,6 +8357,7 @@ public function testDeleteBulkDocumentsWithAFloatFilterDeletesWhatTheFindMatched \sort($found); \sort($deleted); + $this->assertContains('large', $found); $this->assertSame(\count($found), $count); $this->assertSame($found, $deleted); $remaining = \array_map(static fn (Document $document): string => $document->getId(), $database->find($collectionId)); diff --git a/tests/unit/Documents/DeleteDocumentsRaceTest.php b/tests/unit/Documents/DeleteDocumentsRaceTest.php index c051bc380..cdb79be50 100644 --- a/tests/unit/Documents/DeleteDocumentsRaceTest.php +++ b/tests/unit/Documents/DeleteDocumentsRaceTest.php @@ -5,12 +5,15 @@ use Closure; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Tests\Unit\Documents\Interleaving\AuditingPool; use Tests\Unit\Documents\Interleaving\Memory; +use Tests\Unit\Documents\Interleaving\Miscounting; use Tests\Unit\Documents\Interleaving\Refresh; use Tests\Unit\Documents\Interleaving\SQLite; use Tests\Unit\Documents\Interleaving\Standalone; use Utopia\Cache\Adapter\None; use Utopia\Cache\Cache; +use Utopia\Database\Adapter; use Utopia\Database\Attribute; use Utopia\Database\Collection; use Utopia\Database\Database; @@ -27,6 +30,8 @@ use Utopia\Database\RelationshipDeleteAction; use Utopia\Database\Role; use Utopia\Database\Validator\Authorization; +use Utopia\Pools\Adapter\Stack; +use Utopia\Pools\Pool as UtopiaPool; /** * deleteDocuments() deletes only the documents that still match its queries when they are deleted: a document @@ -229,11 +234,21 @@ public function testWithoutATransactionADocumentChangedRightBeforeTheDeleteIsKep $this->assertSame(['b', 'e'], \array_map(static fn (Document $document): string => $document->getId(), $database->find(self::COLLECTION)), 'The kept document keeps its permission rows'); } + /** + * @return iterable}> + */ + public static function floatFinds(): iterable + { + yield 'memory compares exactly' => [static fn (): Memory => new Memory(), ['large']]; + yield 'sqlite binds fixed point' => [static fn (): SQLite => SQLite::inMemory(), ['large', 'tiny']]; + } + /** * @param Closure(): (Memory|SQLite) $adapter + * @param list $expected */ - #[DataProvider('adapters')] - public function testAFloatFilterDeletesTheDocumentsTheFindMatched(Closure $adapter): void + #[DataProvider('floatFinds')] + public function testAFloatFilterDeletesTheDocumentsTheFindMatched(Closure $adapter, array $expected): void { [$database] = $this->database($adapter()); $database->createCollection(Collection::create( @@ -242,17 +257,69 @@ public function testAFloatFilterDeletesTheDocumentsTheFindMatched(Closure $adapt permissions: $this->permissions(), documentSecurity: false, )); - $database->createDocument('scores', new Document([Document::ID => 'tiny', 'score' => 1.0E-21])); - $database->createDocument('scores', new Document([Document::ID => 'none', 'score' => -1.0])); + foreach (['tiny' => 1.0E-21, 'large' => 1.5, 'negative' => -1.0] as $id => $score) { + $database->createDocument('scores', new Document([Document::ID => $id, 'score' => $score])); + } $filter = Query::greaterThan('score', 1.0E-20); $found = \array_map(static fn (Document $document): string => $document->getId(), $database->find('scores', [$filter])); - $count = $database->deleteDocuments('scores', [$filter]); + \sort($found); + $this->assertSame($expected, $found); + + $deleted = []; + $count = $database->deleteDocuments('scores', [$filter], onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + }); + \sort($deleted); $this->assertSame(\count($found), $count, 'The delete binds a float as the find does'); + $this->assertSame($found, $deleted); $remaining = \array_map(static fn (Document $document): string => $document->getId(), $database->find('scores')); $this->assertSame([], \array_values(\array_intersect($found, $remaining))); - $this->assertContains('none', $remaining); + $this->assertContains('negative', $remaining); + } + + public function testAPoolSubclassThatWrapsDeleteDocumentsRunsForAFilteredBatch(): void + { + $engine = new Memory(); + $pool = new AuditingPool(new UtopiaPool(new Stack(), 'race', 2, static fn (): Memory => $engine, timeout: 0.0)); + [$database] = $this->database($pool); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED, 'd' => self::REFRESHED]); + $engine->afterFind = $this->refreshOnce($database, 'b'); + + $count = $database->deleteDocuments(self::COLLECTION, [$this->expired()]); + + $this->assertSame(1, $pool->deletes, 'The pool\'s override of deleteDocuments() runs'); + $this->assertSame(2, $count); + $this->assertSame(['b', 'd'], $this->stored($database), 'The pooled adapter is handed the filters'); + } + + /** + * @return iterable): int}> + */ + public static function miscounts(): iterable + { + yield 'none' => [static fn (int $deleted, array $permissionIds): int => 0]; + yield 'permission rows too' => [static fn (int $deleted, array $permissionIds): int => $deleted + \count($permissionIds)]; + } + + /** + * @param Closure(int, array): int $report + */ + #[DataProvider('miscounts')] + public function testACountTheAdapterGetsWrongDoesNotFailADeleteWithoutARace(Closure $report): void + { + [$database] = $this->database(new Miscounting($report)); + $this->seed($database, ['a' => self::EXPIRED, 'b' => self::EXPIRED, 'c' => self::EXPIRED, 'd' => self::REFRESHED]); + + $deleted = []; + $count = $database->deleteDocuments(self::COLLECTION, [$this->expired()], batchSize: 2, onNext: function (Document $document) use (&$deleted): void { + $deleted[] = $document->getId(); + }); + + $this->assertSame(3, $count); + $this->assertSame(['a', 'b', 'c'], $deleted); + $this->assertSame(['d'], $this->stored($database)); } /** @@ -340,12 +407,12 @@ public function testADeleteWithoutFiltersDeletesEveryDocument(Closure $adapter): } /** - * @template T of Memory|SQLite|Standalone + * @template T of Adapter * * @param T $adapter * @return array{Database, T} */ - private function database(Memory|SQLite|Standalone $adapter): array + private function database(Adapter $adapter): array { $authorization = new Authorization(); $authorization->addRole(self::OWNER); diff --git a/tests/unit/Documents/Interleaving/AuditingPool.php b/tests/unit/Documents/Interleaving/AuditingPool.php new file mode 100644 index 000000000..648078426 --- /dev/null +++ b/tests/unit/Documents/Interleaving/AuditingPool.php @@ -0,0 +1,22 @@ +deletes++; + + return parent::deleteDocuments($collection, $sequences, $permissionIds); + } +} diff --git a/tests/unit/Documents/Interleaving/Miscounting.php b/tests/unit/Documents/Interleaving/Miscounting.php new file mode 100644 index 000000000..66e5a85c4 --- /dev/null +++ b/tests/unit/Documents/Interleaving/Miscounting.php @@ -0,0 +1,27 @@ + $permissionIds): int $report + */ + public function __construct(private readonly Closure $report) + { + parent::__construct(); + } + + #[\Override] + public function deleteDocuments(Document $collection, array $sequences, array $permissionIds): int + { + return ($this->report)(parent::deleteDocuments($collection, $sequences, $permissionIds), $permissionIds); + } +} diff --git a/tests/unit/MongoQueryFilterTest.php b/tests/unit/MongoQueryFilterTest.php index b3eb7d32b..4f38fb2ee 100644 --- a/tests/unit/MongoQueryFilterTest.php +++ b/tests/unit/MongoQueryFilterTest.php @@ -72,6 +72,21 @@ public function testContainsAllKeepsTheAllOperatorOnFind(): void ); } + public function testABulkDeleteNamesTheInternalAttributesOfItsFiltersAsFindDoes(): void + { + $filter = Query::or([Query::equal('$id', ['first']), Query::lessThan('$updatedAt', '2000-01-01T00:00:00.000+00:00')]); + $this->find([$filter]); + $this->createAdapter()->deleteDocumentsMatching(new Document(['$id' => self::COLLECTION]), ['1'], [], [$filter]); + + $found = $this->calls['find'][0]['$and'] ?? null; + + $deleted = $this->calls['delete'][0]['$and'] ?? null; + + $this->assertNotNull($found); + $this->assertIsArray($deleted); + $this->assertSame(['$and' => $found], $deleted[1] ?? null, 'The delete must match the rows the find matched'); + } + public function testCountRethrowsDriverErrors(): void { $this->aggregateError = new MongoException('invalid pipeline', 2); @@ -432,6 +447,19 @@ public function update(string $collection, array $where = [], array $updates = [ return 0; } + /** + * @param array $filters + * @param array $deleteOptions + * @param array $options + */ + #[\Override] + public function delete(string $collection, array $filters = [], int $limit = 1, array $deleteOptions = [], array $options = []): int + { + ($this->record)('delete', $filters); + + return 0; + } + /** * @param array $indexes * @param array $options From c201233700289d4ac54ece9539999d1ca9582928 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 18:40:48 +1300 Subject: [PATCH 10/11] docs: note the count hint, pool overrides and mongodb manual transactions Callers inside a manual transaction on a MongoDB replica set get no retry and keep a partial batch until they roll back, and custom adapters need to know their count is only a hint, so both are spelled out. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++++++++++++-------- UPGRADE.md | 8 +++++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3861854de..76f7a6e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,9 @@ no code change; a custom adapter can opt in to the fix, see deletes the rows of `$sequences` that match every filter query in `$queries`, as `find()` is handed them, removes the permission rows of `$permissionIds` but those of a row kept because it no longer matches, and returns the number of rows deleted. It calls `deleteDocuments()` with the queries readable through the new protected - `Adapter::deletionFilters()`, so an override of `deleteDocuments()` still runs. Memory, SQLite, MariaDB, MySQL, - PostgreSQL, MongoDB and Redis delete only the rows that match them; `Pool` and `ReadWritePool` pass the call on. A - custom adapter that ignores them deletes every row of `$sequences`, as before. + `Adapter::deletionFilters()`, so an override of `deleteDocuments()` still runs, on a `Pool` too. Memory, SQLite, + MariaDB, MySQL, PostgreSQL, MongoDB and Redis delete only the rows that match them; `Pool` and `ReadWritePool` hand + them to the pooled adapter. A custom adapter that ignores them deletes every row of `$sequences`, as before. ### Fixed @@ -29,11 +29,15 @@ no code change; a custom adapter can opt in to the fix, see replaced by a later one. When a document changes between that read and the delete, the batch is rolled back and tried again, and fails with `Exception\Contention` once the transaction's retries are spent; the batches before it stay deleted and `Event::DocumentsDelete` reports them. In a transaction begun with `startTransaction()` the retry - runs in a savepoint, which on MySQL and MariaDB reads the transaction's snapshot again and fails the same way, so the - caller rolls the whole transaction back and runs it again. Without transactions, as on a standalone MongoDB server, - nothing is rolled back: the documents still stored are kept and left out of the count, `onNext`, the cache purges - and the event, the remaining batches are deleted, and a cascade that already ran for a kept document stays, so - cascades are best-effort there. A delete without filters, or with filters only on `$sequence`, runs as before. + runs in a savepoint, which on MySQL and MariaDB reads the transaction's snapshot again and fails the same way; a + MongoDB replica set has no savepoints, so the batch is not retried and its partial delete stays in the transaction. + Either way the caller rolls the whole transaction back and runs it again. Without transactions, as on a standalone + MongoDB server, nothing is rolled back: the documents still stored are kept and left out of the count, `onNext`, + the cache purges and the event, the remaining batches are deleted, a document another deleter removed meanwhile is + counted by this call, and a cascade that already ran for a kept document stays, so cascades are best-effort there. + The adapter's count is a hint: only a document of the batch still stored after the delete counts as changed, so a + custom adapter that reports a wrong count does not fail the delete. A delete without filters, or with filters only + on `$sequence`, runs as before. - A bulk delete on SQLite, MariaDB, MySQL and PostgreSQL binds the floats of its filters in fixed-point notation, as `find()` binds them, so it matches the rows the find matched. diff --git a/UPGRADE.md b/UPGRADE.md index 80a148fd6..79ce574f2 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -11,8 +11,14 @@ every row it is handed, as in 8.1.3; to keep a document another write changed af rows that match every query of `deletionFilters()` (filter queries, as `find()` is handed them), keep the permission rows of a row kept, and return the number of rows deleted. +The count `deleteDocuments()` returns is a hint: a lower count than the rows handed makes `Database::deleteDocuments()` +check which documents are still stored, and a higher one is ignored, so a custom adapter that counts differently still +works. + `deleteDocuments()` can now throw `Exception\Contention` when documents keep changing between the check and the delete -of a batch. Inside a transaction begun with `startTransaction()`, roll it back and run it again. +of a batch. Inside a transaction begun with `startTransaction()`, roll it back and run it again: on MySQL and MariaDB +the retry in the savepoint rereads the same snapshot, and a MongoDB replica set has no savepoint, so the partial +delete of the batch stays in the transaction until it is rolled back. # Upgrading from 8.1.2 to 8.1.3 From 0aaf701c9ba4e5b5f424442077e5847aeb4da2bd Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 15 Oct 2026 18:57:28 +1300 Subject: [PATCH 11/11] test(documents): compare mongodb delete and find filters by value With ext-mongodb loaded, find() and the bulk delete each build their own UTCDateTime for a date filter, and assertSame() compares objects by identity, so equal filters failed in CI. Both filters are now compared with every object replaced by its class and value, which still fails on a different date or a different key. Co-Authored-By: Claude Opus 5.5 --- tests/unit/MongoQueryFilterTest.php | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/unit/MongoQueryFilterTest.php b/tests/unit/MongoQueryFilterTest.php index 4f38fb2ee..045a12816 100644 --- a/tests/unit/MongoQueryFilterTest.php +++ b/tests/unit/MongoQueryFilterTest.php @@ -84,7 +84,7 @@ public function testABulkDeleteNamesTheInternalAttributesOfItsFiltersAsFindDoes( $this->assertNotNull($found); $this->assertIsArray($deleted); - $this->assertSame(['$and' => $found], $deleted[1] ?? null, 'The delete must match the rows the find matched'); + $this->assertSame(self::values(['$and' => $found]), self::values($deleted[1] ?? null), 'The delete must match the rows the find matched'); } public function testCountRethrowsDriverErrors(): void @@ -326,6 +326,27 @@ private function find(array $queries): array return $this->createAdapter()->find(new Document(['$id' => self::COLLECTION]), $queries); } + /** + * The filter with every object, such as a MongoDB\BSON\UTCDateTime, replaced by its class and value, so that two + * filters built separately compare by value. + */ + private static function values(mixed $filter): mixed + { + if (\is_array($filter)) { + return \array_map(self::values(...), $filter); + } + + if ($filter instanceof \Stringable) { + return [$filter::class => (string) $filter]; + } + + if (\is_object($filter)) { + return [$filter::class => self::values(\get_object_vars($filter))]; + } + + return $filter; + } + /** * @param array $filter * @return list