Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 58
Add ignore param to createDocuments for silent duplicate handling#850
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
721b9edb62e93e36b048feb564c359aab83e85729d8cec7c7ba749b91c40fabFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1460,11 +1460,18 @@ public function castingBefore(Document $collection, Document $document): Documen | ||
| * @throws DuplicateException | ||
| * @throws DatabaseException | ||
| */ | ||
| public function createDocuments(Document $collection, array $documents): array | ||
| public function createDocuments(Document $collection, array $documents, bool $ignore = false): array | ||
| { | ||
| $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); | ||
| $options = $this->getTransactionOptions(); | ||
| if ($ignore) { | ||
| // Run outside transaction — MongoDB aborts transactions on any write error, | ||
| // so ordered:false + session would roll back even successfully inserted docs. | ||
| $options = ['ordered' => false]; | ||
| } else { | ||
| $options = $this->getTransactionOptions(); | ||
| } | ||
| $records = []; | ||
| $hasSequence = null; | ||
| $documents = \array_map(fn ($doc) => clone $doc, $documents); | ||
| @@ -1490,7 +1497,16 @@ public function createDocuments(Document $collection, array $documents): array | ||
| try { | ||
| $documents = $this->client->insertMany($name, $records, $options); | ||
| } catch (MongoException $e) { | ||
| throw $this->processException($e); | ||
| $processed = $this->processException($e); | ||
| if ($ignore && $processed instanceof DuplicateException) { | ||
| // Race condition: a doc was inserted between pre-filter and insertMany. | ||
| // With ordered:false outside transaction, non-duplicate inserts persist. | ||
| // Return empty — we cannot determine which docs succeeded without querying. | ||
| return []; | ||
| } | ||
greptile-apps[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| throw $processed; | ||
| } | ||
| foreach ($documents as $index => $document) { | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1365,6 +1365,35 @@ public function updateDocument(Document $collection, string $id, Document $docum | ||
| return $document; | ||
| } | ||
| protected function getInsertKeyword(bool $ignore): string | ||
| { | ||
| return 'INSERT INTO'; | ||
| } | ||
| protected function getInsertSuffix(bool $ignore, string $table): string | ||
| { | ||
| if (!$ignore) { | ||
| return ''; | ||
| } | ||
| $conflictTarget = $this->sharedTables ? '("_uid", "_tenant")' : '("_uid")'; | ||
| return "ON CONFLICT {$conflictTarget} DO NOTHING"; | ||
| } | ||
| protected function getInsertPermissionsSuffix(bool $ignore): string | ||
| { | ||
| if (!$ignore) { | ||
| return ''; | ||
| } | ||
| $conflictTarget = $this->sharedTables | ||
| ? '("_type", "_permission", "_document", "_tenant")' | ||
| : '("_type", "_permission", "_document")'; | ||
| return "ON CONFLICT {$conflictTarget} DO NOTHING"; | ||
| } | ||
Comment on lines
+1373
to
+1395
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The unique indexes created in
This means every
The collation must be specified in the inference list to match the actual index. For example: // getInsertSuffix — non-shared$conflictTarget = '("_uid" COLLATE utf8_ci_ai)';
// getInsertSuffix — shared$conflictTarget = '("_uid" COLLATE utf8_ci_ai, "_tenant")';
// getInsertPermissionsSuffix — non-shared$conflictTarget = '("_document" COLLATE utf8_ci_ai, "_type", "_permission")';ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not an issue. The existing | ||
| /** | ||
| * @param string $tableName | ||
| * @param string $columns | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -2471,7 +2471,7 @@ protected function execute(mixed $stmt): bool | ||
| * @throws DuplicateException | ||
| * @throws \Throwable | ||
| */ | ||
| public function createDocuments(Document $collection, array $documents): array | ||
| public function createDocuments(Document $collection, array $documents, bool $ignore = false): array | ||
| { | ||
| if (empty($documents)) { | ||
| return $documents; | ||
| @@ -2573,8 +2573,9 @@ public function createDocuments(Document $collection, array $documents): array | ||
| $batchKeys = \implode(', ', $batchKeys); | ||
| $stmt = $this->getPDO()->prepare(" | ||
| INSERT INTO {$this->getSQLTable($name)} {$columns} | ||
| {$this->getInsertKeyword($ignore)} {$this->getSQLTable($name)} {$columns} | ||
| VALUES {$batchKeys} | ||
| {$this->getInsertSuffix($ignore, $name)} | ||
| "); | ||
| foreach ($bindValues as $key => $value) { | ||
| @@ -2588,8 +2589,9 @@ public function createDocuments(Document $collection, array $documents): array | ||
| $permissions = \implode(', ', $permissions); | ||
| $sqlPermissions = " | ||
| INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) | ||
| VALUES {$permissions}; | ||
| {$this->getInsertKeyword($ignore)} {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) | ||
| VALUES {$permissions} | ||
| {$this->getInsertPermissionsSuffix($ignore)} | ||
| "; | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); | ||
| @@ -2608,6 +2610,33 @@ public function createDocuments(Document $collection, array $documents): array | ||
| return $documents; | ||
| } | ||
| /** | ||
| * Returns the INSERT keyword, optionally with IGNORE for duplicate handling. | ||
| * Override in adapter subclasses for DB-specific syntax. | ||
| */ | ||
| protected function getInsertKeyword(bool $ignore): string | ||
| { | ||
| return $ignore ? 'INSERT IGNORE INTO' : 'INSERT INTO'; | ||
Comment on lines
+2617
to
+2619
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
MySQL/MariaDB's For strict duplicate-only suppression on MariaDB/MySQL, consider catching the specific PDO error code ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documents are validated by | ||
| } | ||
| /** | ||
| * Returns a suffix appended after VALUES clause for duplicate handling. | ||
| * Override in adapter subclasses (e.g., Postgres uses ON CONFLICT DO NOTHING). | ||
| */ | ||
| protected function getInsertSuffix(bool $ignore, string $table): string | ||
| { | ||
| return ''; | ||
| } | ||
| /** | ||
| * Returns a suffix for the permissions INSERT statement when ignoring duplicates. | ||
| * Override in adapter subclasses for DB-specific syntax. | ||
| */ | ||
| protected function getInsertPermissionsSuffix(bool $ignore): string | ||
| { | ||
| return ''; | ||
| } | ||
| /** | ||
| * @param Document $collection | ||
| * @param string $attribute | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -5621,6 +5621,7 @@ public function createDocument(string $collection, Document $document): Document | ||
| * @param int $batchSize | ||
| * @param (callable(Document): void)|null $onNext | ||
| * @param (callable(Throwable): void)|null $onError | ||
| * @param bool $ignore If true, silently ignore duplicate documents instead of throwing | ||
| * @return int | ||
| * @throws AuthorizationException | ||
| * @throws StructureException | ||
| @@ -5633,6 +5634,7 @@ public function createDocuments( | ||
| int $batchSize = self::INSERT_BATCH_SIZE, | ||
| ?callable $onNext = null, | ||
| ?callable $onError = null, | ||
| bool $ignore = false, | ||
| ): int { | ||
| if (!$this->adapter->getSharedTables() && $this->adapter->getTenantPerDocument()) { | ||
| throw new DatabaseException('Shared tables must be enabled if tenant per document is enabled.'); | ||
| @@ -5653,6 +5655,71 @@ public function createDocuments( | ||
| $time = DateTime::now(); | ||
| $modified = 0; | ||
| // Deduplicate intra-batch documents by ID when ignore mode is on. | ||
| // Keeps the first occurrence, mirrors upsertDocuments' seenIds check. | ||
| if ($ignore) { | ||
| $seenIds = []; | ||
| $deduplicated = []; | ||
| foreach ($documents as $document) { | ||
| $docId = $document->getId(); | ||
| if ($docId !== '' && isset($seenIds[$docId])) { | ||
| continue; | ||
| } | ||
| if ($docId !== '') { | ||
| $seenIds[$docId] = true; | ||
| } | ||
| $deduplicated[] = $document; | ||
| } | ||
| $documents = $deduplicated; | ||
| } | ||
| // When ignore mode is on and relationships are being resolved, | ||
| // pre-fetch existing document IDs so we skip relationship writes for duplicates | ||
| $preExistingIds = []; | ||
| $tenantPerDocument = $this->adapter->getSharedTables() && $this->adapter->getTenantPerDocument(); | ||
| if ($ignore) { | ||
| if ($tenantPerDocument) { | ||
| $idsByTenant = []; | ||
| foreach ($documents as $doc) { | ||
| $idsByTenant[$doc->getTenant()][] = $doc->getId(); | ||
| } | ||
| foreach ($idsByTenant as $tenant => $tenantIds) { | ||
| $tenantIds = \array_values(\array_unique($tenantIds)); | ||
| foreach (\array_chunk($tenantIds, \max(1, $this->maxQueryValues)) as $idChunk) { | ||
| $existing = $this->authorization->skip(fn () => $this->withTenant($tenant, fn () => $this->silent(fn () => $this->find( | ||
| $collection->getId(), | ||
| [ | ||
| Query::equal('$id', $idChunk), | ||
| Query::select(['$id']), | ||
| Query::limit(\count($idChunk)), | ||
| ] | ||
| )))); | ||
| foreach ($existing as $doc) { | ||
| $preExistingIds[$tenant . ':' . $doc->getId()] = true; | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| $inputIds = \array_values(\array_unique(\array_filter( | ||
| \array_map(fn (Document $doc) => $doc->getId(), $documents) | ||
| ))); | ||
| foreach (\array_chunk($inputIds, \max(1, $this->maxQueryValues)) as $idChunk) { | ||
| $existing = $this->authorization->skip(fn () => $this->silent(fn () => $this->find( | ||
| $collection->getId(), | ||
| [ | ||
| Query::equal('$id', $idChunk), | ||
| Query::select(['$id']), | ||
| Query::limit(\count($idChunk)), | ||
| ] | ||
| ))); | ||
| foreach ($existing as $doc) { | ||
| $preExistingIds[$doc->getId()] = true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| foreach ($documents as $document) { | ||
| $createdAt = $document->getCreatedAt(); | ||
| $updatedAt = $document->getUpdatedAt(); | ||
| @@ -5693,15 +5760,33 @@ public function createDocuments( | ||
| } | ||
| if ($this->resolveRelationships) { | ||
| $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); | ||
| $preExistKey = $tenantPerDocument | ||
| ? $document->getTenant() . ':' . $document->getId() | ||
| : $document->getId(); | ||
| if (!isset($preExistingIds[$preExistKey])) { | ||
| $document = $this->silent(fn () => $this->createDocumentRelationships($collection, $document)); | ||
| } | ||
| } | ||
| $document = $this->adapter->castingBefore($collection, $document); | ||
| } | ||
| foreach (\array_chunk($documents, $batchSize) as $chunk) { | ||
| $batch = $this->withTransaction(function () use ($collection, $chunk) { | ||
| return $this->adapter->createDocuments($collection, $chunk); | ||
| if ($ignore && !empty($preExistingIds)) { | ||
| $chunk = \array_values(\array_filter($chunk, function (Document $doc) use ($preExistingIds, $tenantPerDocument) { | ||
| $key = $tenantPerDocument | ||
| ? $doc->getTenant() . ':' . $doc->getId() | ||
| : $doc->getId(); | ||
| return !isset($preExistingIds[$key]); | ||
| })); | ||
| if (empty($chunk)) { | ||
| continue; | ||
| } | ||
| } | ||
| $batch = $this->withTransaction(function () use ($collection, $chunk, $ignore) { | ||
| return $this->adapter->createDocuments($collection, $chunk, $ignore); | ||
| }); | ||
| $batch = $this->adapter->getSequences($collection->getId(), $batch); | ||
| @@ -7116,18 +7201,53 @@ public function upsertDocumentsWithIncrease( | ||
| $created = 0; | ||
| $updated = 0; | ||
| $seenIds = []; | ||
| foreach ($documents as $key => $document) { | ||
| if ($this->getSharedTables() && $this->getTenantPerDocument()) { | ||
| $old = $this->authorization->skip(fn () => $this->withTenant($document->getTenant(), fn () => $this->silent(fn () => $this->getDocument( | ||
| $collection->getId(), | ||
| $document->getId(), | ||
| )))); | ||
| // Batch-fetch existing documents in one query instead of N individual getDocument() calls | ||
| $ids = \array_filter(\array_map(fn ($doc) => $doc->getId(), $documents)); | ||
| $existingDocs = []; | ||
| $upsertTenantPerDocument = $this->getSharedTables() && $this->getTenantPerDocument(); | ||
| if (!empty($ids)) { | ||
| $uniqueIds = \array_values(\array_unique($ids)); | ||
| if ($upsertTenantPerDocument) { | ||
| // Group IDs by tenant and fetch each group separately | ||
| // Use composite key tenant:id to avoid cross-tenant collisions | ||
| $idsByTenant = []; | ||
| foreach ($documents as $doc) { | ||
| $tenant = $doc->getTenant(); | ||
| $idsByTenant[$tenant][] = $doc->getId(); | ||
| } | ||
| foreach ($idsByTenant as $tenant => $tenantIds) { | ||
| $tenantIds = \array_values(\array_unique($tenantIds)); | ||
| foreach (\array_chunk($tenantIds, \max(1, $this->maxQueryValues)) as $idChunk) { | ||
| $fetched = $this->authorization->skip(fn () => $this->withTenant($tenant, fn () => $this->silent(fn () => $this->find( | ||
| $collection->getId(), | ||
| [Query::equal('$id', $idChunk), Query::limit(\count($idChunk))], | ||
| )))); | ||
| foreach ($fetched as $doc) { | ||
| $existingDocs[$tenant . ':' . $doc->getId()] = $doc; | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| $old = $this->authorization->skip(fn () => $this->silent(fn () => $this->getDocument( | ||
| $collection->getId(), | ||
| $document->getId(), | ||
| ))); | ||
| foreach (\array_chunk($uniqueIds, \max(1, $this->maxQueryValues)) as $idChunk) { | ||
| $fetched = $this->authorization->skip(fn () => $this->silent(fn () => $this->find( | ||
| $collection->getId(), | ||
| [Query::equal('$id', $idChunk), Query::limit(\count($idChunk))], | ||
| ))); | ||
| foreach ($fetched as $doc) { | ||
| $existingDocs[$doc->getId()] = $doc; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| foreach ($documents as $key => $document) { | ||
| $lookupKey = $upsertTenantPerDocument | ||
| ? $document->getTenant() . ':' . $document->getId() | ||
| : $document->getId(); | ||
| $old = $existingDocs[$lookupKey] ?? new Document(); | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| // Extract operators early to avoid comparison issues | ||
| $documentArray = $document->getArrayCopy(); | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onNextnever fires for successfully-inserted documents in a mixed-duplicate batchWith
ordered: false, MongoDB processes every document in the batch and only raisesBulkWriteExceptionafter completing all writes — meaning some documents may have been genuinely persisted before the exception. When the catch block returns[],Database.phpiterates that empty array and callsonNextzero times, silently swallowing results for every document actually written in that batch.Callers that use
onNextto stream processed results (cache warming, post-insert hooks, progress tracking) will miss every successfully inserted document from any batch that contained at least one duplicate — which is the common case forignore: truebulk-loads.A more complete fix would recover succeeded writes from the exception itself via
$e->getWriteResult()->getInsertedIds()to re-fetch and return only the actually-inserted documents, rather than returning[]unconditionally.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Known limitation. This only triggers on a race condition — pre-fetch already filters known duplicates before the adapter. The
utopia-php/mongoclient does not exposeBulkWriteException::getWriteResult(), so we cannot recover which docs succeeded in a mixed batch. For the migration use case (single writer), this race cannot occur. Documented as accepted behavior.