From 96e94e0e1117a0aa14d2534040f5f17b51699c0d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 00:30:42 +0000 Subject: [PATCH 01/15] Add automatic cleanup for failed attribute/index/relationship creation This commit implements comprehensive rollback mechanisms to prevent dangling database artifacts when attribute, index, or relationship creation fails during metadata updates. Changes: - createAttribute: Now rolls back created columns if metadata update fails - createIndex: Now rolls back created indexes if metadata update fails - createRelationship: Enhanced rollback to handle: * Junction table cleanup for many-to-many relationships * Index cleanup if index creation fails after metadata update * Comprehensive metadata rollback for all relationship types This ensures that if any step in the creation process fails, all modifications from previous steps are properly undone, preventing users from being left with database artifacts they cannot clean up. --- src/Database/Database.php | 189 ++++++++++++++++++++++++++++++-------- 1 file changed, 149 insertions(+), 40 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index c343191b5..a0d0974e1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1862,16 +1862,29 @@ public function createAttribute(string $collection, string $id, string $type, in } } - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + // Wrap metadata update in try-catch to ensure rollback on failure + try { + if ($collection->getId() !== self::METADATA) { + $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + } - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedDocument(self::METADATA, $collection->getId()); + $this->purgeCachedCollection($collection->getId()); + $this->purgeCachedDocument(self::METADATA, $collection->getId()); - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); - return true; + return true; + } catch (\Throwable $e) { + // Rollback: Remove the attribute that was created + try { + $this->adapter->deleteAttribute($collection->getId(), $id, $type, $size, $signed, $array); + } catch (\Throwable $rollbackException) { + // Log rollback failure but throw original exception + throw new DatabaseException('Failed to create attribute metadata and rollback failed: ' . $e->getMessage() . ' | Rollback error: ' . $rollbackException->getMessage(), previous: $e); + } + + throw new DatabaseException('Failed to create attribute metadata: ' . $e->getMessage(), previous: $e); + } } /** @@ -2967,8 +2980,11 @@ public function createRelationship( $collection->setAttribute('attributes', $relationship, Document::SET_TYPE_APPEND); $relatedCollection->setAttribute('attributes', $twoWayRelationship, Document::SET_TYPE_APPEND); + // Track junction collection name for rollback + $junctionCollection = null; if ($type === self::RELATION_MANY_TO_MANY) { - $this->silent(fn () => $this->createCollection('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence(), [ + $junctionCollection = '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence(); + $this->silent(fn () => $this->createCollection($junctionCollection, [ new Document([ '$id' => $id, 'key' => $id, @@ -3015,25 +3031,48 @@ public function createRelationship( ); if (!$created) { + // Rollback junction table if it was created + if ($junctionCollection !== null) { + try { + $this->silent(fn () => $this->deleteCollection($junctionCollection)); + } catch (\Throwable $e) { + // Continue to throw the main error + } + } throw new DatabaseException('Failed to create relationship'); } - $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey) { + $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $junctionCollection) { + $indexesCreated = []; try { $this->withTransaction(function () use ($collection, $relatedCollection) { $this->updateDocument(self::METADATA, $collection->getId(), $collection); $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); }); } catch (\Throwable $e) { - $this->adapter->deleteRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey, - Database::RELATION_SIDE_PARENT - ); + // Rollback adapter relationship + try { + $this->adapter->deleteRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey, + Database::RELATION_SIDE_PARENT + ); + } catch (\Throwable $rollbackException) { + // Continue to rollback junction table + } + + // Rollback junction table if it was created + if ($junctionCollection !== null) { + try { + $this->deleteCollection($junctionCollection); + } catch (\Throwable $rollbackException) { + // Continue to throw original error + } + } throw new DatabaseException('Failed to create relationship: ' . $e->getMessage()); } @@ -3041,24 +3080,81 @@ public function createRelationship( $indexKey = '_index_' . $id; $twoWayIndexKey = '_index_' . $twoWayKey; - switch ($type) { - case self::RELATION_ONE_TO_ONE: - $this->createIndex($collection->getId(), $indexKey, self::INDEX_UNIQUE, [$id]); - if ($twoWay) { - $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_UNIQUE, [$twoWayKey]); + try { + switch ($type) { + case self::RELATION_ONE_TO_ONE: + $this->createIndex($collection->getId(), $indexKey, self::INDEX_UNIQUE, [$id]); + $indexesCreated[] = ['collection' => $collection->getId(), 'index' => $indexKey]; + if ($twoWay) { + $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_UNIQUE, [$twoWayKey]); + $indexesCreated[] = ['collection' => $relatedCollection->getId(), 'index' => $twoWayIndexKey]; + } + break; + case self::RELATION_ONE_TO_MANY: + $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_KEY, [$twoWayKey]); + $indexesCreated[] = ['collection' => $relatedCollection->getId(), 'index' => $twoWayIndexKey]; + break; + case self::RELATION_MANY_TO_ONE: + $this->createIndex($collection->getId(), $indexKey, self::INDEX_KEY, [$id]); + $indexesCreated[] = ['collection' => $collection->getId(), 'index' => $indexKey]; + break; + case self::RELATION_MANY_TO_MANY: + // Indexes created on junction collection creation + break; + default: + throw new RelationshipException('Invalid relationship type.'); + } + } catch (\Throwable $e) { + // Rollback any indexes that were successfully created + foreach ($indexesCreated as $indexInfo) { + try { + $this->deleteIndex($indexInfo['collection'], $indexInfo['index']); + } catch (\Throwable $rollbackException) { + // Continue rollback } - break; - case self::RELATION_ONE_TO_MANY: - $this->createIndex($relatedCollection->getId(), $twoWayIndexKey, self::INDEX_KEY, [$twoWayKey]); - break; - case self::RELATION_MANY_TO_ONE: - $this->createIndex($collection->getId(), $indexKey, self::INDEX_KEY, [$id]); - break; - case self::RELATION_MANY_TO_MANY: - // Indexes created on junction collection creation - break; - default: - throw new RelationshipException('Invalid relationship type.'); + } + + // Rollback metadata updates via transaction + try { + $this->withTransaction(function () use ($collection, $relatedCollection, $id, $twoWayKey) { + // Remove relationship attributes from collections + $attributes = $collection->getAttribute('attributes', []); + $collection->setAttribute('attributes', array_filter($attributes, fn($attr) => $attr->getId() !== $id)); + $this->updateDocument(self::METADATA, $collection->getId(), $collection); + + $relatedAttributes = $relatedCollection->getAttribute('attributes', []); + $relatedCollection->setAttribute('attributes', array_filter($relatedAttributes, fn($attr) => $attr->getId() !== $twoWayKey)); + $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + }); + } catch (\Throwable $rollbackException) { + // Continue rollback + } + + // Rollback adapter relationship + try { + $this->adapter->deleteRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey, + Database::RELATION_SIDE_PARENT + ); + } catch (\Throwable $rollbackException) { + // Continue rollback + } + + // Rollback junction table if it was created + if ($junctionCollection !== null) { + try { + $this->deleteCollection($junctionCollection); + } catch (\Throwable $rollbackException) { + // Continue to throw original error + } + } + + throw new DatabaseException('Failed to create relationship indexes: ' . $e->getMessage()); } }); @@ -3606,13 +3702,26 @@ public function createIndex(string $collection, string $id, string $type, array } } - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + // Wrap metadata update in try-catch to ensure rollback on failure + try { + if ($collection->getId() !== self::METADATA) { + $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + } - $this->trigger(self::EVENT_INDEX_CREATE, $index); + $this->trigger(self::EVENT_INDEX_CREATE, $index); - return true; + return true; + } catch (\Throwable $e) { + // Rollback: Remove the index that was created + try { + $this->adapter->deleteIndex($collection->getId(), $id); + } catch (\Throwable $rollbackException) { + // Log rollback failure but throw original exception + throw new DatabaseException('Failed to create index metadata and rollback failed: ' . $e->getMessage() . ' | Rollback error: ' . $rollbackException->getMessage(), previous: $e); + } + + throw new DatabaseException('Failed to create index metadata: ' . $e->getMessage(), previous: $e); + } } /** From edbabf9c32b3fafa03d04ff52f1c1f1e38e6b038 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 20:59:39 +1300 Subject: [PATCH 02/15] Add cleanup + retries on DDL failure --- src/Database/Database.php | 999 ++++++++++++++++++++++++++------------ 1 file changed, 689 insertions(+), 310 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index a0d0974e1..c80935d5e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3,6 +3,7 @@ namespace Utopia\Database; use Exception; +use Swoole\Coroutine; use Throwable; use Utopia\Cache\Cache; use Utopia\CLI\Console; @@ -1843,11 +1844,8 @@ public function createAttribute(string $collection, string $id, string $type, in $filters ); - $collection->setAttribute( - 'attributes', - $attribute, - Document::SET_TYPE_APPEND - ); + $created = false; + $duplicateInSharedTable = false; try { $created = $this->adapter->createAttribute($collection->getId(), $id, $type, $size, $signed, $array, $required); @@ -1860,31 +1858,28 @@ public function createAttribute(string $collection, string $id, string $type, in if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { throw $e; } + $duplicateInSharedTable = true; } - // Wrap metadata update in try-catch to ensure rollback on failure - try { - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $collection->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedDocument(self::METADATA, $collection->getId()); + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->cleanupAttribute($collection->getId(), $id), + shouldRollback: $created, + operationDescription: "attribute creation '{$id}'" + ); - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); - return true; - } catch (\Throwable $e) { - // Rollback: Remove the attribute that was created - try { - $this->adapter->deleteAttribute($collection->getId(), $id, $type, $size, $signed, $array); - } catch (\Throwable $rollbackException) { - // Log rollback failure but throw original exception - throw new DatabaseException('Failed to create attribute metadata and rollback failed: ' . $e->getMessage() . ' | Rollback error: ' . $rollbackException->getMessage(), previous: $e); - } + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection->getId(), + '$collection' => self::METADATA + ])); + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); - throw new DatabaseException('Failed to create attribute metadata: ' . $e->getMessage(), previous: $e); - } + return true; } /** @@ -1960,15 +1955,12 @@ public function createAttributes(string $collection, array $attributes): bool $attribute['filters'] ); - $collection->setAttribute( - 'attributes', - $attributeDocument, - Document::SET_TYPE_APPEND - ); - $attributeDocuments[] = $attributeDocument; } + $created = false; + $duplicateInSharedTable = false; + try { $created = $this->adapter->createAttributes($collection->getId(), $attributes); @@ -1981,15 +1973,28 @@ public function createAttributes(string $collection, array $attributes): bool if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { throw $e; } + $duplicateInSharedTable = true; } - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + foreach ($attributeDocuments as $attributeDocument) { + $collection->setAttribute('attributes', $attributeDocument, Document::SET_TYPE_APPEND); } - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedDocument(self::METADATA, $collection->getId()); + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->cleanupAttributes($collection->getId(), $attributeDocuments), + shouldRollback: $created, + operationDescription: 'attributes creation', + rollbackReturnsErrors: true + ); + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); + + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection->getId(), + '$collection' => self::METADATA + ])); $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attributeDocuments); return true; @@ -2275,12 +2280,17 @@ protected function updateIndexMeta(string $collection, string $id, callable $upd // Execute update from callback $updateCallback($indexes[$index], $collection, $index); - // Save $collection->setAttribute('indexes', $indexes); - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + $this->withRetries( + fn () => $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) + ); - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $indexes[$index]); + try { + $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $indexes[$index]); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return $indexes[$index]; } @@ -2314,12 +2324,17 @@ protected function updateAttributeMeta(string $collection, string $id, callable // Execute update from callback $updateCallback($attributes[$index], $collection, $index); - // Save $collection->setAttribute('attributes', $attributes); - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + $this->withRetries( + fn () => $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) + ); - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attributes[$index]); + try { + $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attributes[$index]); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return $attributes[$index]; } @@ -2439,255 +2454,307 @@ public function updateAttributeDefault(string $collection, string $id, mixed $de */ public function updateAttribute(string $collection, string $id, ?string $type = null, ?int $size = null, ?bool $required = null, mixed $default = null, ?bool $signed = null, ?bool $array = null, ?string $format = null, ?array $formatOptions = null, ?array $filters = null, ?string $newKey = null): Document { - return $this->updateAttributeMeta($collection, $id, function ($attribute, $collectionDoc, $attributeIndex) use ($collection, $id, $type, $size, $required, $default, $signed, $array, $format, $formatOptions, $filters, $newKey) { + $collectionDoc = $this->silent(fn () => $this->getCollection($collection)); - // Store original indexes before any modifications (deep copy preserving Document objects) - $originalIndexes = []; - foreach ($collectionDoc->getAttribute('indexes', []) as $index) { - $originalIndexes[] = clone $index; - } + if ($collectionDoc->getId() === self::METADATA) { + throw new DatabaseException('Cannot update metadata attributes'); + } - $altering = !\is_null($type) - || !\is_null($size) - || !\is_null($signed) - || !\is_null($array) - || !\is_null($newKey); - $type ??= $attribute->getAttribute('type'); - $size ??= $attribute->getAttribute('size'); - $signed ??= $attribute->getAttribute('signed'); - $required ??= $attribute->getAttribute('required'); - $default ??= $attribute->getAttribute('default'); - $array ??= $attribute->getAttribute('array'); - $format ??= $attribute->getAttribute('format'); - $formatOptions ??= $attribute->getAttribute('formatOptions'); - $filters ??= $attribute->getAttribute('filters'); + $attributes = $collectionDoc->getAttribute('attributes', []); + $attributeIndex = \array_search($id, \array_map(fn ($attribute) => $attribute['$id'], $attributes)); - if ($required === true && !\is_null($default)) { - $default = null; - } + if ($attributeIndex === false) { + throw new NotFoundException('Attribute not found'); + } - // we need to alter table attribute type to NOT NULL/NULL for change in required - if (!$this->adapter->getSupportForSpatialIndexNull() && in_array($type, Database::SPATIAL_TYPES)) { - $altering = true; - } + $attribute = $attributes[$attributeIndex]; - switch ($type) { - case self::VAR_STRING: - if (empty($size)) { - throw new DatabaseException('Size length is required'); - } + $originalType = $attribute->getAttribute('type'); + $originalSize = $attribute->getAttribute('size'); + $originalSigned = $attribute->getAttribute('signed'); + $originalArray = $attribute->getAttribute('array'); + $originalRequired = $attribute->getAttribute('required'); + $originalKey = $attribute->getAttribute('key'); - if ($size > $this->adapter->getLimitForString()) { - throw new DatabaseException('Max size allowed for string is: ' . number_format($this->adapter->getLimitForString())); - } - break; + $originalIndexes = []; + foreach ($collectionDoc->getAttribute('indexes', []) as $index) { + $originalIndexes[] = clone $index; + } - case self::VAR_INTEGER: - $limit = ($signed) ? $this->adapter->getLimitForInt() / 2 : $this->adapter->getLimitForInt(); - if ($size > $limit) { - throw new DatabaseException('Max size allowed for int is: ' . number_format($limit)); - } - break; - case self::VAR_FLOAT: - case self::VAR_BOOLEAN: - case self::VAR_DATETIME: - if (!empty($size)) { - throw new DatabaseException('Size must be empty'); - } - break; + $altering = !\is_null($type) + || !\is_null($size) + || !\is_null($signed) + || !\is_null($array) + || !\is_null($newKey); + $type ??= $attribute->getAttribute('type'); + $size ??= $attribute->getAttribute('size'); + $signed ??= $attribute->getAttribute('signed'); + $required ??= $attribute->getAttribute('required'); + $default ??= $attribute->getAttribute('default'); + $array ??= $attribute->getAttribute('array'); + $format ??= $attribute->getAttribute('format'); + $formatOptions ??= $attribute->getAttribute('formatOptions'); + $filters ??= $attribute->getAttribute('filters'); - case self::VAR_POINT: - case self::VAR_LINESTRING: - case self::VAR_POLYGON: - if (!$this->adapter->getSupportForSpatialAttributes()) { - throw new DatabaseException('Spatial attributes are not supported'); - } - if (!empty($size)) { - throw new DatabaseException('Size must be empty for spatial attributes'); - } - if (!empty($array)) { - throw new DatabaseException('Spatial attributes cannot be arrays'); - } - break; - case self::VAR_VECTOR: - if (!$this->adapter->getSupportForVectors()) { - throw new DatabaseException('Vector types are not supported by the current database'); - } - if ($array) { - throw new DatabaseException('Vector type cannot be an array'); - } - if ($size <= 0) { - throw new DatabaseException('Vector dimensions must be a positive integer'); + if ($required === true && !\is_null($default)) { + $default = null; + } + + // we need to alter table attribute type to NOT NULL/NULL for change in required + if (!$this->adapter->getSupportForSpatialIndexNull() && in_array($type, Database::SPATIAL_TYPES)) { + $altering = true; + } + + switch ($type) { + case self::VAR_STRING: + if (empty($size)) { + throw new DatabaseException('Size length is required'); + } + + if ($size > $this->adapter->getLimitForString()) { + throw new DatabaseException('Max size allowed for string is: ' . number_format($this->adapter->getLimitForString())); + } + break; + + case self::VAR_INTEGER: + $limit = ($signed) ? $this->adapter->getLimitForInt() / 2 : $this->adapter->getLimitForInt(); + if ($size > $limit) { + throw new DatabaseException('Max size allowed for int is: ' . number_format($limit)); + } + break; + case self::VAR_FLOAT: + case self::VAR_BOOLEAN: + case self::VAR_DATETIME: + if (!empty($size)) { + throw new DatabaseException('Size must be empty'); + } + break; + + case self::VAR_POINT: + case self::VAR_LINESTRING: + case self::VAR_POLYGON: + if (!$this->adapter->getSupportForSpatialAttributes()) { + throw new DatabaseException('Spatial attributes are not supported'); + } + if (!empty($size)) { + throw new DatabaseException('Size must be empty for spatial attributes'); + } + if (!empty($array)) { + throw new DatabaseException('Spatial attributes cannot be arrays'); + } + break; + case self::VAR_VECTOR: + if (!$this->adapter->getSupportForVectors()) { + throw new DatabaseException('Vector types are not supported by the current database'); + } + if ($array) { + throw new DatabaseException('Vector type cannot be an array'); + } + if ($size <= 0) { + throw new DatabaseException('Vector dimensions must be a positive integer'); + } + if ($size > self::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed ' . self::MAX_VECTOR_DIMENSIONS); + } + if ($default !== null) { + if (!\is_array($default)) { + throw new DatabaseException('Vector default value must be an array'); } - if ($size > self::MAX_VECTOR_DIMENSIONS) { - throw new DatabaseException('Vector dimensions cannot exceed ' . self::MAX_VECTOR_DIMENSIONS); + if (\count($default) !== $size) { + throw new DatabaseException('Vector default value must have exactly ' . $size . ' elements'); } - if ($default !== null) { - if (!\is_array($default)) { - throw new DatabaseException('Vector default value must be an array'); - } - if (\count($default) !== $size) { - throw new DatabaseException('Vector default value must have exactly ' . $size . ' elements'); - } - foreach ($default as $component) { - if (!\is_int($component) && !\is_float($component)) { - throw new DatabaseException('Vector default value must contain only numeric elements'); - } + foreach ($default as $component) { + if (!\is_int($component) && !\is_float($component)) { + throw new DatabaseException('Vector default value must contain only numeric elements'); } } - break; - default: - $supportedTypes = [ - self::VAR_STRING, - self::VAR_INTEGER, - self::VAR_FLOAT, - self::VAR_BOOLEAN, - self::VAR_DATETIME, - self::VAR_RELATIONSHIP - ]; - if ($this->adapter->getSupportForVectors()) { - $supportedTypes[] = self::VAR_VECTOR; - } - if ($this->adapter->getSupportForSpatialAttributes()) { - \array_push($supportedTypes, ...self::SPATIAL_TYPES); - } - throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); - } + } + break; + default: + $supportedTypes = [ + self::VAR_STRING, + self::VAR_INTEGER, + self::VAR_FLOAT, + self::VAR_BOOLEAN, + self::VAR_DATETIME, + self::VAR_RELATIONSHIP + ]; + if ($this->adapter->getSupportForVectors()) { + $supportedTypes[] = self::VAR_VECTOR; + } + if ($this->adapter->getSupportForSpatialAttributes()) { + \array_push($supportedTypes, ...self::SPATIAL_TYPES); + } + throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes)); + } + + /** Ensure required filters for the attribute are passed */ + $requiredFilters = $this->getRequiredFilters($type); + if (!empty(array_diff($requiredFilters, $filters))) { + throw new DatabaseException("Attribute of type: $type requires the following filters: " . implode(",", $requiredFilters)); + } - /** Ensure required filters for the attribute are passed */ - $requiredFilters = $this->getRequiredFilters($type); - if (!empty(array_diff($requiredFilters, $filters))) { - throw new DatabaseException("Attribute of type: $type requires the following filters: " . implode(",", $requiredFilters)); + if ($format) { + if (!Structure::hasFormat($format, $type)) { + throw new DatabaseException('Format ("' . $format . '") not available for this attribute type ("' . $type . '")'); } + } - if ($format) { - if (!Structure::hasFormat($format, $type)) { - throw new DatabaseException('Format ("' . $format . '") not available for this attribute type ("' . $type . '")'); - } + if (!\is_null($default)) { + if ($required) { + throw new DatabaseException('Cannot set a default value on a required attribute'); } - if (!\is_null($default)) { - if ($required) { - throw new DatabaseException('Cannot set a default value on a required attribute'); - } + $this->validateDefaultTypes($type, $default); + } - $this->validateDefaultTypes($type, $default); - } + $attribute + ->setAttribute('$id', $newKey ?? $id) + ->setattribute('key', $newKey ?? $id) + ->setAttribute('type', $type) + ->setAttribute('size', $size) + ->setAttribute('signed', $signed) + ->setAttribute('array', $array) + ->setAttribute('format', $format) + ->setAttribute('formatOptions', $formatOptions) + ->setAttribute('filters', $filters) + ->setAttribute('required', $required) + ->setAttribute('default', $default); - $attribute - ->setAttribute('$id', $newKey ?? $id) - ->setattribute('key', $newKey ?? $id) - ->setAttribute('type', $type) - ->setAttribute('size', $size) - ->setAttribute('signed', $signed) - ->setAttribute('array', $array) - ->setAttribute('format', $format) - ->setAttribute('formatOptions', $formatOptions) - ->setAttribute('filters', $filters) - ->setAttribute('required', $required) - ->setAttribute('default', $default); + $attributes = $collectionDoc->getAttribute('attributes'); + $attributes[$attributeIndex] = $attribute; + $collectionDoc->setAttribute('attributes', $attributes, Document::SET_TYPE_ASSIGN); - $attributes = $collectionDoc->getAttribute('attributes'); - $attributes[$attributeIndex] = $attribute; - $collectionDoc->setAttribute('attributes', $attributes, Document::SET_TYPE_ASSIGN); + if ( + $this->adapter->getDocumentSizeLimit() > 0 && + $this->adapter->getAttributeWidth($collectionDoc) >= $this->adapter->getDocumentSizeLimit() + ) { + throw new LimitException('Row width limit reached. Cannot update attribute.'); + } - if ( - $this->adapter->getDocumentSizeLimit() > 0 && - $this->adapter->getAttributeWidth($collectionDoc) >= $this->adapter->getDocumentSizeLimit() - ) { - throw new LimitException('Row width limit reached. Cannot update attribute.'); + if (in_array($type, self::SPATIAL_TYPES, true) && !$this->adapter->getSupportForSpatialIndexNull()) { + $attributeMap = []; + foreach ($attributes as $attrDoc) { + $key = \strtolower($attrDoc->getAttribute('key', $attrDoc->getAttribute('$id'))); + $attributeMap[$key] = $attrDoc; } - if (in_array($type, self::SPATIAL_TYPES, true) && !$this->adapter->getSupportForSpatialIndexNull()) { - $attributeMap = []; - foreach ($attributes as $attrDoc) { - $key = \strtolower($attrDoc->getAttribute('key', $attrDoc->getAttribute('$id'))); - $attributeMap[$key] = $attrDoc; + $indexes = $collectionDoc->getAttribute('indexes', []); + foreach ($indexes as $index) { + if ($index->getAttribute('type') !== self::INDEX_SPATIAL) { + continue; } - - $indexes = $collectionDoc->getAttribute('indexes', []); - foreach ($indexes as $index) { - if ($index->getAttribute('type') !== self::INDEX_SPATIAL) { + $indexAttributes = $index->getAttribute('attributes', []); + foreach ($indexAttributes as $attributeName) { + $lookup = \strtolower($attributeName); + if (!isset($attributeMap[$lookup])) { continue; } - $indexAttributes = $index->getAttribute('attributes', []); - foreach ($indexAttributes as $attributeName) { - $lookup = \strtolower($attributeName); - if (!isset($attributeMap[$lookup])) { - continue; - } - $attrDoc = $attributeMap[$lookup]; - $attrType = $attrDoc->getAttribute('type'); - $attrRequired = (bool)$attrDoc->getAttribute('required', false); + $attrDoc = $attributeMap[$lookup]; + $attrType = $attrDoc->getAttribute('type'); + $attrRequired = (bool)$attrDoc->getAttribute('required', false); - if (in_array($attrType, self::SPATIAL_TYPES, true) && !$attrRequired) { - throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'); - } + if (in_array($attrType, self::SPATIAL_TYPES, true) && !$attrRequired) { + throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'); } } } + } - if ($altering) { - $indexes = $collectionDoc->getAttribute('indexes'); - - if (!\is_null($newKey) && $id !== $newKey) { - foreach ($indexes as $index) { - if (in_array($id, $index['attributes'])) { - $index['attributes'] = array_map(function ($attribute) use ($id, $newKey) { - return $attribute === $id ? $newKey : $attribute; - }, $index['attributes']); - } - } + $updated = false; - /** - * Check index dependency if we are changing the key - */ - $validator = new IndexDependencyValidator( - $collectionDoc->getAttribute('indexes', []), - $this->adapter->getSupportForCastIndexArray(), - ); + if ($altering) { + $indexes = $collectionDoc->getAttribute('indexes'); - if (!$validator->isValid($attribute)) { - throw new DependencyException($validator->getDescription()); + if (!\is_null($newKey) && $id !== $newKey) { + foreach ($indexes as $index) { + if (in_array($id, $index['attributes'])) { + $index['attributes'] = array_map(function ($attribute) use ($id, $newKey) { + return $attribute === $id ? $newKey : $attribute; + }, $index['attributes']); } } /** - * Since we allow changing type & size we need to validate index length + * Check index dependency if we are changing the key */ - if ($this->validate) { - $validator = new IndexValidator( - $attributes, - $originalIndexes, - $this->adapter->getMaxIndexLength(), - $this->adapter->getInternalIndexesKeys(), - $this->adapter->getSupportForIndexArray(), - $this->adapter->getSupportForSpatialIndexNull(), - $this->adapter->getSupportForSpatialIndexOrder(), - $this->adapter->getSupportForVectors(), - $this->adapter->getSupportForAttributes(), - $this->adapter->getSupportForMultipleFulltextIndexes(), - $this->adapter->getSupportForIdenticalIndexes(), - ); + $validator = new IndexDependencyValidator( + $collectionDoc->getAttribute('indexes', []), + $this->adapter->getSupportForCastIndexArray(), + ); - foreach ($indexes as $index) { - if (!$validator->isValid($index)) { - throw new IndexException($validator->getDescription()); - } - } + if (!$validator->isValid($attribute)) { + throw new DependencyException($validator->getDescription()); } + } - $updated = $this->adapter->updateAttribute($collection, $id, $type, $size, $signed, $array, $newKey, $required); + /** + * Since we allow changing type & size we need to validate index length + */ + if ($this->validate) { + $validator = new IndexValidator( + $attributes, + $originalIndexes, + $this->adapter->getMaxIndexLength(), + $this->adapter->getInternalIndexesKeys(), + $this->adapter->getSupportForIndexArray(), + $this->adapter->getSupportForSpatialIndexNull(), + $this->adapter->getSupportForSpatialIndexOrder(), + $this->adapter->getSupportForVectors(), + $this->adapter->getSupportForAttributes(), + $this->adapter->getSupportForMultipleFulltextIndexes(), + $this->adapter->getSupportForIdenticalIndexes(), + ); - if (!$updated) { - throw new DatabaseException('Failed to update attribute'); + foreach ($indexes as $index) { + if (!$validator->isValid($index)) { + throw new IndexException($validator->getDescription()); + } } + } + + $updated = $this->adapter->updateAttribute($collection, $id, $type, $size, $signed, $array, $newKey, $required); - $this->purgeCachedCollection($collection); + if (!$updated) { + throw new DatabaseException('Failed to update attribute'); } + } - $this->purgeCachedDocument(self::METADATA, $collection); - }); + $collectionDoc->setAttribute('attributes', $attributes); + + $this->updateMetadata( + collection: $collectionDoc, + rollbackOperation: fn () => $this->adapter->updateAttribute( + $collection, + $newKey ?? $id, + $originalType, + $originalSize, + $originalSigned, + $originalArray, + $originalKey, + $originalRequired + ), + shouldRollback: $updated, + operationDescription: "attribute update '{$id}'", + silentRollback: true + ); + + if ($altering) { + $this->withRetries(fn () => $this->purgeCachedCollection($collection)); + } + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection)); + + try { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection, + '$collection' => self::METADATA + ])); + $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } + + return $attribute; } /** @@ -2781,25 +2848,48 @@ public function deleteAttribute(string $collection, string $id): bool } } + $success = false; try { if (!$this->adapter->deleteAttribute($collection->getId(), $id)) { throw new DatabaseException('Failed to delete attribute'); } + $success = true; } catch (NotFoundException) { // Ignore + $success = false; } $collection->setAttribute('attributes', \array_values($attributes)); $collection->setAttribute('indexes', \array_values($indexes)); - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->adapter->createAttribute( + $collection->getId(), + $id, + $attribute['type'], + $attribute['size'], + $attribute['signed'] ?? false, + $attribute['array'] ?? false, + $attribute['required'] ?? false + ), + shouldRollback: $success, + operationDescription: "attribute deletion '{$id}'", + silentRollback: true + ); - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedDocument(self::METADATA, $collection->getId()); + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); - $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $attribute); + try { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection->getId(), + '$collection' => self::METADATA + ])); + $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $attribute); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return true; } @@ -2883,6 +2973,55 @@ public function renameAttribute(string $collection, string $old, string $new): b return $renamed; } + /** + * Cleanup (delete) a single attribute with retry logic + * + * @param string $collectionId The collection ID + * @param string $attributeId The attribute ID + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupAttribute( + string $collectionId, + string $attributeId, + int $maxAttempts = 3 + ): void { + $this->cleanup( + fn () => $this->adapter->deleteAttribute($collectionId, $attributeId), + 'attribute', + $attributeId, + $maxAttempts + ); + } + + /** + * Cleanup (delete) multiple attributes with retry logic + * + * @param string $collectionId The collection ID + * @param array $attributeDocuments The attribute documents to cleanup + * @param int $maxAttempts Maximum retry attempts per attribute + * @return array Array of error messages for failed cleanups (empty if all succeeded) + */ + private function cleanupAttributes( + string $collectionId, + array $attributeDocuments, + int $maxAttempts = 3 + ): array { + $errors = []; + + foreach ($attributeDocuments as $attributeDocument) { + try { + $this->cleanupAttribute($collectionId, $attributeDocument->getId(), $maxAttempts); + } catch (DatabaseException $e) { + // Continue cleaning up other attributes even if one fails + $errors[] = $e->getMessage(); + } + } + + return $errors; + } + /** * Create a relationship attribute * @@ -2977,8 +3116,6 @@ public function createRelationship( $this->checkAttribute($collection, $relationship); $this->checkAttribute($relatedCollection, $twoWayRelationship); - $collection->setAttribute('attributes', $relationship, Document::SET_TYPE_APPEND); - $relatedCollection->setAttribute('attributes', $twoWayRelationship, Document::SET_TYPE_APPEND); // Track junction collection name for rollback $junctionCollection = null; @@ -3042,6 +3179,9 @@ public function createRelationship( throw new DatabaseException('Failed to create relationship'); } + $collection->setAttribute('attributes', $relationship, Document::SET_TYPE_APPEND); + $relatedCollection->setAttribute('attributes', $twoWayRelationship, Document::SET_TYPE_APPEND); + $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $junctionCollection) { $indexesCreated = []; try { @@ -3050,6 +3190,9 @@ public function createRelationship( $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); }); } catch (\Throwable $e) { + $this->rollbackAttributeMetadata($collection, [$id]); + $this->rollbackAttributeMetadata($relatedCollection, [$twoWayKey]); + // Rollback adapter relationship try { $this->adapter->deleteRelationship( @@ -3119,11 +3262,11 @@ public function createRelationship( $this->withTransaction(function () use ($collection, $relatedCollection, $id, $twoWayKey) { // Remove relationship attributes from collections $attributes = $collection->getAttribute('attributes', []); - $collection->setAttribute('attributes', array_filter($attributes, fn($attr) => $attr->getId() !== $id)); + $collection->setAttribute('attributes', array_filter($attributes, fn ($attr) => $attr->getId() !== $id)); $this->updateDocument(self::METADATA, $collection->getId(), $collection); $relatedAttributes = $relatedCollection->getAttribute('attributes', []); - $relatedCollection->setAttribute('attributes', array_filter($relatedAttributes, fn($attr) => $attr->getId() !== $twoWayKey)); + $relatedCollection->setAttribute('attributes', array_filter($relatedAttributes, fn ($attr) => $attr->getId() !== $twoWayKey)); $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); }); } catch (\Throwable $rollbackException) { @@ -3272,7 +3415,7 @@ public function updateRelationship( $junctionAttribute->setAttribute('key', $newTwoWayKey); }); - $this->purgeCachedCollection($junction); + $this->withRetries(fn () => $this->purgeCachedCollection($junction)); } if ($altering) { @@ -3359,8 +3502,8 @@ function ($index) use ($newKey) { throw new RelationshipException('Invalid relationship type.'); } - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedCollection($relatedCollection->getId()); + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); return true; } @@ -3415,16 +3558,46 @@ public function deleteRelationship(string $collection, string $id): bool $relatedCollection->setAttribute('attributes', \array_values($relatedAttributes)); - $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $side) { - try { - $this->withTransaction(function () use ($collection, $relatedCollection) { - $this->updateDocument(self::METADATA, $collection->getId(), $collection); - $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + $deleted = $this->adapter->deleteRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey, + $side + ); + + if (!$deleted) { + throw new DatabaseException('Failed to delete relationship'); + } + + try { + $this->withRetries(function () use ($collection, $relatedCollection) { + $this->silent(function () use ($collection, $relatedCollection) { + $this->withTransaction(function () use ($collection, $relatedCollection) { + $this->updateDocument(self::METADATA, $collection->getId(), $collection); + $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + }); }); - } catch (\Throwable $e) { - throw new DatabaseException('Failed to delete relationship: ' . $e->getMessage()); + }); + } catch (\Throwable $e) { + try { + $this->adapter->createRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey + ); + } catch (\Throwable $rollbackError) { + // Log rollback failure but don't throw - we're already handling an error } + throw new DatabaseException('Failed to persist metadata after retries: ' . $e->getMessage()); + } + $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $side) { $indexKey = '_index_' . $id; $twoWayIndexKey = '_index_' . $twoWayKey; @@ -3471,25 +3644,15 @@ public function deleteRelationship(string $collection, string $id): bool } }); - $deleted = $this->adapter->deleteRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey, - $side - ); + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); - if (!$deleted) { - throw new DatabaseException('Failed to delete relationship'); + try { + $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $relationship); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation } - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedCollection($relatedCollection->getId()); - - $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $relationship); - return true; } @@ -3686,7 +3849,8 @@ public function createIndex(string $collection, string $id, string $type, array } } - $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); + $created = false; + $duplicateInSharedTable = false; try { $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes); @@ -3696,32 +3860,24 @@ public function createIndex(string $collection, string $id, string $type, array } } catch (DuplicateException $e) { // HACK: Metadata should still be updated, can be removed when null tenant collections are supported. - if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { throw $e; } + $duplicateInSharedTable = true; } - // Wrap metadata update in try-catch to ensure rollback on failure - try { - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); - $this->trigger(self::EVENT_INDEX_CREATE, $index); + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->cleanupIndex($collection->getId(), $id), + shouldRollback: $created, + operationDescription: "index creation '{$id}'" + ); - return true; - } catch (\Throwable $e) { - // Rollback: Remove the index that was created - try { - $this->adapter->deleteIndex($collection->getId(), $id); - } catch (\Throwable $rollbackException) { - // Log rollback failure but throw original exception - throw new DatabaseException('Failed to create index metadata and rollback failed: ' . $e->getMessage() . ' | Rollback error: ' . $rollbackException->getMessage(), previous: $e); - } + $this->trigger(self::EVENT_INDEX_CREATE, $index); - throw new DatabaseException('Failed to create index metadata: ' . $e->getMessage(), previous: $e); - } + return true; } /** @@ -3750,15 +3906,39 @@ public function deleteIndex(string $collection, string $id): bool } } + if (\is_null($indexDeleted)) { + throw new NotFoundException('Index not found'); + } + $deleted = $this->adapter->deleteIndex($collection->getId(), $id); + if (!$deleted) { + throw new DatabaseException('Failed to delete index'); + } + $collection->setAttribute('indexes', \array_values($indexes)); - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->adapter->createIndex( + $collection->getId(), + $id, + $indexDeleted['type'], + $indexDeleted['attributes'], + $indexDeleted['lengths'] ?? [], + $indexDeleted['orders'] ?? [] + ), + shouldRollback: $deleted, + operationDescription: "index deletion '{$id}'", + silentRollback: true + ); - $this->trigger(self::EVENT_INDEX_DELETE, $indexDeleted); + + try { + $this->trigger(self::EVENT_INDEX_DELETE, $indexDeleted); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return $deleted; } @@ -6021,7 +6201,7 @@ public function upsertDocumentsWithIncrease( $document->getId(), )))); } else { - $old = $this->authorization->skip(fn () => $this->silent(fn () => $this->getDocument( + $old = $this->authorization->skip(fn () => $this->silent(fn () => $this->getDocument( $collection->getId(), $document->getId(), ))); @@ -7127,7 +7307,7 @@ public function purgeCachedCollection(string $collectionId): bool * @return bool * @throws Exception */ - public function purgeCachedDocument(string $collectionId, ?string $id): bool + protected function purgeCachedDocumentInternal(string $collectionId, ?string $id): bool { if ($id === null) { return true; @@ -7138,14 +7318,34 @@ public function purgeCachedDocument(string $collectionId, ?string $id): bool $this->cache->purge($collectionKey, $documentKey); $this->cache->purge($documentKey); - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $id, - '$collection' => $collectionId - ])); - return true; } + /** + * Cleans a specific document from cache and triggers EVENT_DOCUMENT_PURGE. + * And related document reference in the collection cache. + * + * Note: Do not retry this method as it triggers events. Use purgeCachedDocumentInternal() with retry instead. + * + * @param string $collectionId + * @param string|null $id + * @return bool + * @throws Exception + */ + public function purgeCachedDocument(string $collectionId, ?string $id): bool + { + $result = $this->purgeCachedDocumentInternal($collectionId, $id); + + if ($id !== null) { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $id, + '$collection' => $collectionId + ])); + } + + return $result; + } + /** * Find Documents * @@ -7242,7 +7442,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $cursor = $this->adapter->castingBefore($collection, $cursor); $cursor = $cursor->getArrayCopy(); } else { - $cursor = []; + $cursor = []; } /** @var array $queries */ @@ -8627,4 +8827,183 @@ protected function encodeSpatialData(mixed $value, string $type): string throw new DatabaseException('Unknown spatial type: ' . $type); } } + + /** + * Retry a callable with exponential backoff + * + * @param callable $operation The operation to retry + * @param int $maxAttempts Maximum number of retry attempts + * @param int $initialDelayMs Initial delay in milliseconds + * @param float $multiplier Backoff multiplier + * @return void The result of the operation + * @throws \Throwable The last exception if all retries fail + */ + private function withRetries( + callable $operation, + int $maxAttempts = 3, + int $initialDelayMs = 100, + float $multiplier = 2.0 + ): void { + $attempt = 0; + $delayMs = $initialDelayMs; + $lastException = null; + + while ($attempt < $maxAttempts) { + try { + $operation(); + return; + } catch (\Throwable $e) { + $lastException = $e; + $attempt++; + + if ($attempt >= $maxAttempts) { + break; + } + + if (Coroutine::getCid() > 0) { + Coroutine::sleep($delayMs * 1000); + } else { + \usleep($delayMs * 1000); + } + + $delayMs = (int)($delayMs * $multiplier); + } + } + + throw $lastException; + } + + /** + * Generic cleanup operation with retry logic + * + * @param callable $operation The cleanup operation to execute + * @param string $resourceType Type of resource being cleaned up (e.g., 'attribute', 'index') + * @param string $resourceId ID of the resource being cleaned up + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanup( + callable $operation, + string $resourceType, + string $resourceId, + int $maxAttempts = 3 + ): void { + try { + $this->withRetries($operation, maxAttempts: $maxAttempts); + } catch (\Throwable $e) { + Console::error("Failed to cleanup {$resourceType} '{$resourceId}' after {$maxAttempts} attempts: " . $e->getMessage()); + throw $e; + } + } + + /** + * Cleanup (delete) an index with retry logic + * + * @param string $collectionId The collection ID + * @param string $indexId The index ID + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupIndex( + string $collectionId, + string $indexId, + int $maxAttempts = 3 + ): void { + $this->cleanup( + fn () => $this->adapter->deleteIndex($collectionId, $indexId), + 'index', + $indexId, + $maxAttempts + ); + } + + /** + * Persist metadata with automatic rollback on failure + * + * Centralizes the common pattern of: + * 1. Attempting to persist metadata with retry + * 2. Rolling back database operations if metadata persistence fails + * 3. Providing detailed error messages for both success and failure scenarios + * + * @param Document $collection The collection document to persist + * @param callable|null $rollbackOperation Cleanup operation to run if persistence fails (null if no cleanup needed) + * @param bool $shouldRollback Whether rollback should be attempted (e.g., false for duplicates in shared tables) + * @param string $operationDescription Description of the operation for error messages + * @param bool $rollbackReturnsErrors Whether rollback operation returns error array (true) or throws (false) + * @param bool $silentRollback Whether rollback errors should be silently caught (true) or thrown (false) + * @return void + * @throws DatabaseException If metadata persistence fails after all retries + */ + private function updateMetadata( + Document $collection, + ?callable $rollbackOperation, + bool $shouldRollback, + string $operationDescription = 'operation', + bool $rollbackReturnsErrors = false, + bool $silentRollback = false + ): void { + try { + if ($collection->getId() !== self::METADATA) { + $this->withRetries( + fn () => $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) + ); + } + } catch (\Throwable $e) { + // Attempt rollback only if conditions are met + if ($shouldRollback && $rollbackOperation !== null) { + if ($rollbackReturnsErrors) { + // Batch mode: rollback returns array of errors + $cleanupErrors = $rollbackOperation(); + if (!empty($cleanupErrors)) { + throw new DatabaseException( + "Failed to persist metadata after retries and cleanup encountered errors for {$operationDescription}: " . $e->getMessage() . ' | Cleanup errors: ' . implode(', ', $cleanupErrors), + previous: $e + ); + } + } elseif ($silentRollback) { + // Silent mode: swallow rollback errors + try { + $rollbackOperation(); + } catch (\Throwable $rollbackError) { + // Silent rollback - errors are swallowed + } + } else { + // Regular mode: rollback throws on failure + try { + $rollbackOperation(); + } catch (\Throwable $rollbackException) { + throw new DatabaseException( + "Failed to persist metadata after retries and cleanup failed for {$operationDescription}: " . $e->getMessage() . ' | Cleanup error: ' . $rollbackException->getMessage(), + previous: $e + ); + } + } + } + + throw new DatabaseException( + "Failed to persist metadata after retries for {$operationDescription}: " . $e->getMessage(), + previous: $e + ); + } + } + + /** + * Rollback metadata state by removing specified attributes from collection + * + * @param Document $collection The collection document + * @param array $attributeIds Attribute IDs to remove + * @return void + */ + private function rollbackAttributeMetadata(Document $collection, array $attributeIds): void + { + $attributes = $collection->getAttribute('attributes', []); + $filteredAttributes = \array_filter( + $attributes, + fn ($attr) => !\in_array($attr->getId(), $attributeIds) + ); + $collection->setAttribute('attributes', \array_values($filteredAttributes)); + } + } From ec1df0e88d53fc2dcb7b5c9006a78655438fab2d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 21:52:18 +1300 Subject: [PATCH 03/15] Fix tests --- src/Database/Database.php | 124 ++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 66 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index c80935d5e..606ead479 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2848,6 +2848,9 @@ public function deleteAttribute(string $collection, string $id): bool } } + $collection->setAttribute('attributes', \array_values($attributes)); + $collection->setAttribute('indexes', \array_values($indexes)); + $success = false; try { if (!$this->adapter->deleteAttribute($collection->getId(), $id)) { @@ -2859,24 +2862,9 @@ public function deleteAttribute(string $collection, string $id): bool $success = false; } - $collection->setAttribute('attributes', \array_values($attributes)); - $collection->setAttribute('indexes', \array_values($indexes)); - - $this->updateMetadata( - collection: $collection, - rollbackOperation: fn () => $this->adapter->createAttribute( - $collection->getId(), - $id, - $attribute['type'], - $attribute['size'], - $attribute['signed'] ?? false, - $attribute['array'] ?? false, - $attribute['required'] ?? false - ), - shouldRollback: $success, - operationDescription: "attribute deletion '{$id}'", - silentRollback: true - ); + if ($collection->getId() !== self::METADATA) { + $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + } $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); @@ -3193,7 +3181,6 @@ public function createRelationship( $this->rollbackAttributeMetadata($collection, [$id]); $this->rollbackAttributeMetadata($relatedCollection, [$twoWayKey]); - // Rollback adapter relationship try { $this->adapter->deleteRelationship( $collection->getId(), @@ -3204,7 +3191,7 @@ public function createRelationship( $twoWayKey, Database::RELATION_SIDE_PARENT ); - } catch (\Throwable $rollbackException) { + } catch (\Throwable $e) { // Continue to rollback junction table } @@ -3222,6 +3209,7 @@ public function createRelationship( $indexKey = '_index_' . $id; $twoWayIndexKey = '_index_' . $twoWayKey; + $indexesCreated = []; try { switch ($type) { @@ -3248,19 +3236,16 @@ public function createRelationship( throw new RelationshipException('Invalid relationship type.'); } } catch (\Throwable $e) { - // Rollback any indexes that were successfully created foreach ($indexesCreated as $indexInfo) { try { $this->deleteIndex($indexInfo['collection'], $indexInfo['index']); - } catch (\Throwable $rollbackException) { + } catch (\Throwable $e) { // Continue rollback } } - // Rollback metadata updates via transaction try { $this->withTransaction(function () use ($collection, $relatedCollection, $id, $twoWayKey) { - // Remove relationship attributes from collections $attributes = $collection->getAttribute('attributes', []); $collection->setAttribute('attributes', array_filter($attributes, fn ($attr) => $attr->getId() !== $id)); $this->updateDocument(self::METADATA, $collection->getId(), $collection); @@ -3273,7 +3258,6 @@ public function createRelationship( // Continue rollback } - // Rollback adapter relationship try { $this->adapter->deleteRelationship( $collection->getId(), @@ -3284,15 +3268,14 @@ public function createRelationship( $twoWayKey, Database::RELATION_SIDE_PARENT ); - } catch (\Throwable $rollbackException) { + } catch (\Throwable $e) { // Continue rollback } - // Rollback junction table if it was created if ($junctionCollection !== null) { try { $this->deleteCollection($junctionCollection); - } catch (\Throwable $rollbackException) { + } catch (\Throwable $e) { // Continue to throw original error } } @@ -3558,45 +3541,10 @@ public function deleteRelationship(string $collection, string $id): bool $relatedCollection->setAttribute('attributes', \array_values($relatedAttributes)); - $deleted = $this->adapter->deleteRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey, - $side - ); - - if (!$deleted) { - throw new DatabaseException('Failed to delete relationship'); - } - - try { - $this->withRetries(function () use ($collection, $relatedCollection) { - $this->silent(function () use ($collection, $relatedCollection) { - $this->withTransaction(function () use ($collection, $relatedCollection) { - $this->updateDocument(self::METADATA, $collection->getId(), $collection); - $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); - }); - }); - }); - } catch (\Throwable $e) { - try { - $this->adapter->createRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey - ); - } catch (\Throwable $rollbackError) { - // Log rollback failure but don't throw - we're already handling an error - } - throw new DatabaseException('Failed to persist metadata after retries: ' . $e->getMessage()); - } + $collectionAttributes = $collection->getAttribute('attributes'); + $relatedCollectionAttributes = $relatedCollection->getAttribute('attributes'); + // Delete indexes BEFORE dropping columns to avoid referencing non-existent columns $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $side) { $indexKey = '_index_' . $id; $twoWayIndexKey = '_index_' . $twoWayKey; @@ -3644,6 +3592,50 @@ public function deleteRelationship(string $collection, string $id): bool } }); + $collection = $this->silent(fn () => $this->getCollection($collection->getId())); + $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollection->getId())); + $collection->setAttribute('attributes', $collectionAttributes); + $relatedCollection->setAttribute('attributes', $relatedCollectionAttributes); + + $deleted = $this->adapter->deleteRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey, + $side + ); + + if (!$deleted) { + throw new DatabaseException('Failed to delete relationship'); + } + + try { + $this->withRetries(function () use ($collection, $relatedCollection) { + $this->silent(function () use ($collection, $relatedCollection) { + $this->withTransaction(function () use ($collection, $relatedCollection) { + $this->updateDocument(self::METADATA, $collection->getId(), $collection); + $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + }); + }); + }); + } catch (\Throwable $e) { + try { + $this->adapter->createRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey + ); + } catch (\Throwable $rollbackError) { + // Log rollback failure but don't throw - we're already handling an error + } + throw new DatabaseException('Failed to persist metadata after retries: ' . $e->getMessage()); + } + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); $this->withRetries(fn () => $this->purgeCachedCollection($relatedCollection->getId())); From d13b10a579b4d0819240bdbfc1b3af2d52a94719 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 22:02:10 +1300 Subject: [PATCH 04/15] Fix swoole sleep delay --- src/Database/Database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 606ead479..df7a70a2e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8853,7 +8853,7 @@ private function withRetries( } if (Coroutine::getCid() > 0) { - Coroutine::sleep($delayMs * 1000); + Coroutine::sleep($delayMs / 1000); } else { \usleep($delayMs * 1000); } From 623ef23732efe376a5bcd6305c7167d42cf20146 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 22:03:41 +1300 Subject: [PATCH 05/15] Remove invalid event call --- src/Database/Database.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index df7a70a2e..41055c17e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2286,12 +2286,6 @@ protected function updateIndexMeta(string $collection, string $id, callable $upd fn () => $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) ); - try { - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $indexes[$index]); - } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation - } - return $indexes[$index]; } From e07b198ae9a0580d5ae7f8b575ae5804587a0bd2 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 22:09:40 +1300 Subject: [PATCH 06/15] Remove redundant var --- src/Database/Database.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 41055c17e..2c12e0aac 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1845,7 +1845,6 @@ public function createAttribute(string $collection, string $id, string $type, in ); $created = false; - $duplicateInSharedTable = false; try { $created = $this->adapter->createAttribute($collection->getId(), $id, $type, $size, $signed, $array, $required); @@ -1858,7 +1857,6 @@ public function createAttribute(string $collection, string $id, string $type, in if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { throw $e; } - $duplicateInSharedTable = true; } $collection->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); @@ -1959,7 +1957,6 @@ public function createAttributes(string $collection, array $attributes): bool } $created = false; - $duplicateInSharedTable = false; try { $created = $this->adapter->createAttributes($collection->getId(), $attributes); @@ -1973,7 +1970,6 @@ public function createAttributes(string $collection, array $attributes): bool if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { throw $e; } - $duplicateInSharedTable = true; } foreach ($attributeDocuments as $attributeDocument) { @@ -3836,7 +3832,6 @@ public function createIndex(string $collection, string $id, string $type, array } $created = false; - $duplicateInSharedTable = false; try { $created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes); @@ -3849,7 +3844,6 @@ public function createIndex(string $collection, string $id, string $type, array if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { throw $e; } - $duplicateInSharedTable = true; } $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); From 767b4b217608d2c7447c07bb7be47271514d4afb Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 22:25:48 +1300 Subject: [PATCH 07/15] Use helper --- src/Database/Database.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 2c12e0aac..97936233e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2278,8 +2278,11 @@ protected function updateIndexMeta(string $collection, string $id, callable $upd $collection->setAttribute('indexes', $indexes); - $this->withRetries( - fn () => $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) + $this->updateMetadata( + collection: $collection, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "index metadata update '{$id}'" ); return $indexes[$index]; @@ -2316,8 +2319,11 @@ protected function updateAttributeMeta(string $collection, string $id, callable $collection->setAttribute('attributes', $attributes); - $this->withRetries( - fn () => $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) + $this->updateMetadata( + collection: $collection, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "attribute metadata update '{$id}'" ); try { From ab5b315d8bb58371a9b3b203219e9e8cf14db13d Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 22:42:25 +1300 Subject: [PATCH 08/15] Rollback on collection create fail --- src/Database/Database.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 97936233e..14c61c327 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1566,8 +1566,11 @@ public function createCollection(string $id, array $attributes = [], array $inde } } + $created = false; + try { $this->adapter->createCollection($id, $attributes, $indexes); + $created = true; } catch (DuplicateException $e) { // HACK: Metadata should still be updated, can be removed when null tenant collections are supported. if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { @@ -1579,7 +1582,18 @@ public function createCollection(string $id, array $attributes = [], array $inde return new Document(self::COLLECTION); } - $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); + try { + $createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection)); + } catch (\Throwable $e) { + if ($created) { + try { + $this->adapter->deleteCollection($id); + } catch (\Throwable $rollbackError) { + // Log rollback failure but continue throwing original error + } + } + throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); + } $this->trigger(self::EVENT_COLLECTION_CREATE, $createdCollection); From 91c5fee63c0f1cef601bde79426294d1a69dc5a4 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 23:51:39 +1300 Subject: [PATCH 09/15] Don't fail on event failure --- src/Database/Database.php | 350 ++++++++++++++++++++++++++------------ 1 file changed, 237 insertions(+), 113 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 14c61c327..f1210ba83 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1375,7 +1375,11 @@ public function create(?string $database = null): bool $this->silent(fn () => $this->createCollection(self::METADATA, $attributes)); - $this->trigger(self::EVENT_DATABASE_CREATE, $database); + try { + $this->trigger(self::EVENT_DATABASE_CREATE, $database); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return true; } @@ -1405,7 +1409,11 @@ public function list(): array { $databases = $this->adapter->list(); - $this->trigger(self::EVENT_DATABASE_LIST, $databases); + try { + $this->trigger(self::EVENT_DATABASE_LIST, $databases); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return $databases; } @@ -1423,10 +1431,14 @@ public function delete(?string $database = null): bool $deleted = $this->adapter->delete($database); - $this->trigger(self::EVENT_DATABASE_DELETE, [ - 'name' => $database, - 'deleted' => $deleted - ]); + try { + $this->trigger(self::EVENT_DATABASE_DELETE, [ + 'name' => $database, + 'deleted' => $deleted + ]); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } $this->cache->flush(); @@ -1595,7 +1607,11 @@ public function createCollection(string $id, array $attributes = [], array $inde throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); } - $this->trigger(self::EVENT_COLLECTION_CREATE, $createdCollection); + try { + $this->trigger(self::EVENT_COLLECTION_CREATE, $createdCollection); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return $createdCollection; } @@ -1639,7 +1655,11 @@ public function updateCollection(string $id, array $permissions, bool $documentS $collection = $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - $this->trigger(self::EVENT_COLLECTION_UPDATE, $collection); + try { + $this->trigger(self::EVENT_COLLECTION_UPDATE, $collection); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return $collection; } @@ -1665,7 +1685,11 @@ public function getCollection(string $id): Document return new Document(); } - $this->trigger(self::EVENT_COLLECTION_READ, $collection); + try { + $this->trigger(self::EVENT_COLLECTION_READ, $collection); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return $collection; } @@ -1686,7 +1710,11 @@ public function listCollections(int $limit = 25, int $offset = 0): array Query::offset($offset) ])); - $this->trigger(self::EVENT_COLLECTION_LIST, $result); + try { + $this->trigger(self::EVENT_COLLECTION_LIST, $result); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return $result; } @@ -1796,7 +1824,11 @@ public function deleteCollection(string $id): bool } if ($deleted) { - $this->trigger(self::EVENT_COLLECTION_DELETE, $collection); + try { + $this->trigger(self::EVENT_COLLECTION_DELETE, $collection); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } } $this->purgeCachedCollection($id); @@ -1885,11 +1917,20 @@ public function createAttribute(string $collection, string $id, string $type, in $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $collection->getId(), - '$collection' => self::METADATA - ])); - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); + try { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection->getId(), + '$collection' => self::METADATA + ])); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } + + try { + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return true; } @@ -2001,11 +2042,20 @@ public function createAttributes(string $collection, array $attributes): bool $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); - $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ - '$id' => $collection->getId(), - '$collection' => self::METADATA - ])); - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attributeDocuments); + try { + $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ + '$id' => $collection->getId(), + '$collection' => self::METADATA + ])); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } + + try { + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attributeDocuments); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return true; } @@ -2759,6 +2809,11 @@ public function updateAttribute(string $collection, string $id, ?string $type = '$id' => $collection, '$collection' => self::METADATA ])); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } + + try { $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); } catch (\Throwable $e) { // Log but don't throw - event failures shouldn't fail the operation @@ -2872,9 +2927,21 @@ public function deleteAttribute(string $collection, string $id): bool $success = false; } - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->adapter->createAttribute( + $collection->getId(), + $id, + $attribute['type'], + $attribute['size'], + $attribute['signed'] ?? false, + $attribute['array'] ?? false, + $attribute['required'] ?? false + ), + shouldRollback: $success, + operationDescription: "attribute deletion '{$id}'", + silentRollback: true + ); $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); @@ -2884,6 +2951,11 @@ public function deleteAttribute(string $collection, string $id): bool '$id' => $collection->getId(), '$collection' => self::METADATA ])); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } + + try { $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $attribute); } catch (\Throwable $e) { // Log but don't throw - event failures shouldn't fail the operation @@ -2957,16 +3029,31 @@ public function renameAttribute(string $collection, string $old, string $new): b $index->setAttribute('attributes', $indexAttributes); } - $renamed = $this->adapter->renameAttribute($collection->getId(), $old, $new); + $renamed = false; + try { + $renamed = $this->adapter->renameAttribute($collection->getId(), $old, $new); + if (!$renamed) { + throw new DatabaseException('Failed to rename attribute'); + } + } catch (\Throwable $e) { + throw new DatabaseException("Failed to rename attribute '{$old}' to '{$new}': " . $e->getMessage(), previous: $e); + } $collection->setAttribute('attributes', $attributes); $collection->setAttribute('indexes', $indexes); - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->adapter->renameAttribute($collection->getId(), $new, $old), + shouldRollback: $renamed, + operationDescription: "attribute rename '{$old}' to '{$new}'" + ); - $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); + try { + $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return $renamed; } @@ -3294,7 +3381,11 @@ public function createRelationship( } }); - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $relationship); + try { + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $relationship); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return true; } @@ -3352,83 +3443,107 @@ public function updateRelationship( $relatedCollectionId = $attribute['options']['relatedCollection']; $relatedCollection = $this->getCollection($relatedCollectionId); - $this->updateAttributeMeta($collection->getId(), $id, function ($attribute) use ($collection, $id, $newKey, $newTwoWayKey, $twoWay, $onDelete, $type, $side) { - $altering = (!\is_null($newKey) && $newKey !== $id) - || (!\is_null($newTwoWayKey) && $newTwoWayKey !== $attribute['options']['twoWayKey']); + // Determine if we need to alter the database (rename columns/indexes) + $oldAttribute = $attributes[$attributeIndex]; + $oldTwoWayKey = $oldAttribute['options']['twoWayKey']; + $altering = (!\is_null($newKey) && $newKey !== $id) + || (!\is_null($newTwoWayKey) && $newTwoWayKey !== $oldTwoWayKey); - $relatedCollectionId = $attribute['options']['relatedCollection']; - $relatedCollection = $this->getCollection($relatedCollectionId); - $relatedAttributes = $relatedCollection->getAttribute('attributes', []); + // Validate new keys don't already exist + if ( + !\is_null($newTwoWayKey) + && \in_array($newTwoWayKey, \array_map(fn ($attribute) => $attribute['key'], $relatedCollection->getAttribute('attributes', []))) + ) { + throw new DuplicateException('Related attribute already exists'); + } - if ( - !\is_null($newTwoWayKey) - && \in_array($newTwoWayKey, \array_map(fn ($attribute) => $attribute['key'], $relatedAttributes)) - ) { - throw new DuplicateException('Related attribute already exists'); - } + $actualNewKey = $newKey ?? $id; + $actualNewTwoWayKey = $newTwoWayKey ?? $oldTwoWayKey; + $actualTwoWay = $twoWay ?? $oldAttribute['options']['twoWay']; + $actualOnDelete = $onDelete ?? $oldAttribute['options']['onDelete']; - $newKey ??= $attribute['key']; - $twoWayKey = $attribute['options']['twoWayKey']; - $newTwoWayKey ??= $attribute['options']['twoWayKey']; - $twoWay ??= $attribute['options']['twoWay']; - $onDelete ??= $attribute['options']['onDelete']; + $adapterUpdated = false; + if ($altering) { + try { + $adapterUpdated = $this->adapter->updateRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $actualTwoWay, + $id, + $oldTwoWayKey, + $side, + $actualNewKey, + $actualNewTwoWayKey + ); - $attribute->setAttribute('$id', $newKey); - $attribute->setAttribute('key', $newKey); - $attribute->setAttribute('options', [ - 'relatedCollection' => $relatedCollection->getId(), - 'relationType' => $type, - 'twoWay' => $twoWay, - 'twoWayKey' => $newTwoWayKey, - 'onDelete' => $onDelete, - 'side' => $side, - ]); + if (!$adapterUpdated) { + throw new DatabaseException('Failed to update relationship'); + } + } catch (\Throwable $e) { + throw new DatabaseException("Failed to update relationship '{$id}': " . $e->getMessage(), previous: $e); + } + } + try { + $this->updateAttributeMeta($collection->getId(), $id, function ($attribute) use ($actualNewKey, $actualNewTwoWayKey, $actualTwoWay, $actualOnDelete, $relatedCollection, $type, $side) { + $attribute->setAttribute('$id', $actualNewKey); + $attribute->setAttribute('key', $actualNewKey); + $attribute->setAttribute('options', [ + 'relatedCollection' => $relatedCollection->getId(), + 'relationType' => $type, + 'twoWay' => $actualTwoWay, + 'twoWayKey' => $actualNewTwoWayKey, + 'onDelete' => $actualOnDelete, + 'side' => $side, + ]); + }); - $this->updateAttributeMeta($relatedCollection->getId(), $twoWayKey, function ($twoWayAttribute) use ($newKey, $newTwoWayKey, $twoWay, $onDelete) { + $this->updateAttributeMeta($relatedCollection->getId(), $oldTwoWayKey, function ($twoWayAttribute) use ($actualNewKey, $actualNewTwoWayKey, $actualTwoWay, $actualOnDelete) { $options = $twoWayAttribute->getAttribute('options', []); - $options['twoWayKey'] = $newKey; - $options['twoWay'] = $twoWay; - $options['onDelete'] = $onDelete; + $options['twoWayKey'] = $actualNewKey; + $options['twoWay'] = $actualTwoWay; + $options['onDelete'] = $actualOnDelete; - $twoWayAttribute->setAttribute('$id', $newTwoWayKey); - $twoWayAttribute->setAttribute('key', $newTwoWayKey); + $twoWayAttribute->setAttribute('$id', $actualNewTwoWayKey); + $twoWayAttribute->setAttribute('key', $actualNewTwoWayKey); $twoWayAttribute->setAttribute('options', $options); }); if ($type === self::RELATION_MANY_TO_MANY) { $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - $this->updateAttributeMeta($junction, $id, function ($junctionAttribute) use ($newKey) { - $junctionAttribute->setAttribute('$id', $newKey); - $junctionAttribute->setAttribute('key', $newKey); + $this->updateAttributeMeta($junction, $id, function ($junctionAttribute) use ($actualNewKey) { + $junctionAttribute->setAttribute('$id', $actualNewKey); + $junctionAttribute->setAttribute('key', $actualNewKey); }); - $this->updateAttributeMeta($junction, $twoWayKey, function ($junctionAttribute) use ($newTwoWayKey) { - $junctionAttribute->setAttribute('$id', $newTwoWayKey); - $junctionAttribute->setAttribute('key', $newTwoWayKey); + $this->updateAttributeMeta($junction, $oldTwoWayKey, function ($junctionAttribute) use ($actualNewTwoWayKey) { + $junctionAttribute->setAttribute('$id', $actualNewTwoWayKey); + $junctionAttribute->setAttribute('key', $actualNewTwoWayKey); }); $this->withRetries(fn () => $this->purgeCachedCollection($junction)); } - - if ($altering) { - $updated = $this->adapter->updateRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey, - $side, - $newKey, - $newTwoWayKey - ); - - if (!$updated) { - throw new DatabaseException('Failed to update relationship'); + } catch (\Throwable $e) { + if ($adapterUpdated) { + try { + $this->adapter->updateRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $actualTwoWay, + $actualNewKey, + $actualNewTwoWayKey, + $side, + $id, + $oldTwoWayKey + ); + } catch (\Throwable $rollbackError) { + // Log rollback failure but continue throwing original error } } - }); + throw $e; + } // Update Indexes $renameIndex = function (string $collection, string $key, string $newKey) { @@ -3444,51 +3559,45 @@ function ($index) use ($newKey) { ); }; - $newKey ??= $attribute['key']; - $twoWayKey = $attribute['options']['twoWayKey']; - $newTwoWayKey ??= $attribute['options']['twoWayKey']; - $twoWay ??= $attribute['options']['twoWay']; - $onDelete ??= $attribute['options']['onDelete']; - switch ($type) { case self::RELATION_ONE_TO_ONE: - if ($id !== $newKey) { - $renameIndex($collection->getId(), $id, $newKey); + if ($id !== $actualNewKey) { + $renameIndex($collection->getId(), $id, $actualNewKey); } - if ($twoWay && $twoWayKey !== $newTwoWayKey) { - $renameIndex($relatedCollection->getId(), $twoWayKey, $newTwoWayKey); + if ($actualTwoWay && $oldTwoWayKey !== $actualNewTwoWayKey) { + $renameIndex($relatedCollection->getId(), $oldTwoWayKey, $actualNewTwoWayKey); } break; case self::RELATION_ONE_TO_MANY: if ($side === Database::RELATION_SIDE_PARENT) { - if ($twoWayKey !== $newTwoWayKey) { - $renameIndex($relatedCollection->getId(), $twoWayKey, $newTwoWayKey); + if ($oldTwoWayKey !== $actualNewTwoWayKey) { + $renameIndex($relatedCollection->getId(), $oldTwoWayKey, $actualNewTwoWayKey); } } else { - if ($id !== $newKey) { - $renameIndex($collection->getId(), $id, $newKey); + if ($id !== $actualNewKey) { + $renameIndex($collection->getId(), $id, $actualNewKey); } } break; case self::RELATION_MANY_TO_ONE: if ($side === Database::RELATION_SIDE_PARENT) { - if ($id !== $newKey) { - $renameIndex($collection->getId(), $id, $newKey); + if ($id !== $actualNewKey) { + $renameIndex($collection->getId(), $id, $actualNewKey); } } else { - if ($twoWayKey !== $newTwoWayKey) { - $renameIndex($relatedCollection->getId(), $twoWayKey, $newTwoWayKey); + if ($oldTwoWayKey !== $actualNewTwoWayKey) { + $renameIndex($relatedCollection->getId(), $oldTwoWayKey, $actualNewTwoWayKey); } } break; case self::RELATION_MANY_TO_MANY: $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); - if ($id !== $newKey) { - $renameIndex($junction, $id, $newKey); + if ($id !== $actualNewKey) { + $renameIndex($junction, $id, $actualNewKey); } - if ($twoWayKey !== $newTwoWayKey) { - $renameIndex($junction, $twoWayKey, $newTwoWayKey); + if ($oldTwoWayKey !== $actualNewTwoWayKey) { + $renameIndex($junction, $oldTwoWayKey, $actualNewTwoWayKey); } break; default: @@ -3701,13 +3810,28 @@ public function renameIndex(string $collection, string $old, string $new): bool $collection->setAttribute('indexes', $indexes); - $this->adapter->renameIndex($collection->getId(), $old, $new); - - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + $renamed = false; + try { + $renamed = $this->adapter->renameIndex($collection->getId(), $old, $new); + if (!$renamed) { + throw new DatabaseException('Failed to rename index'); + } + } catch (\Throwable $e) { + throw new DatabaseException("Failed to rename index '{$old}' to '{$new}': " . $e->getMessage(), previous: $e); } - $this->trigger(self::EVENT_INDEX_RENAME, $indexNew); + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->adapter->renameIndex($collection->getId(), $new, $old), + shouldRollback: $renamed, + operationDescription: "index rename '{$old}' to '{$new}'" + ); + + try { + $this->trigger(self::EVENT_INDEX_RENAME, $indexNew); + } catch (\Throwable $e) { + // Log but don't throw - event failures shouldn't fail the operation + } return true; } From ba0a8c940b6855d25a5fd5b44ed70271000bc325 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 23:52:51 +1300 Subject: [PATCH 10/15] Ext check --- src/Database/Database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index f1210ba83..8c8faae29 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8984,7 +8984,7 @@ private function withRetries( break; } - if (Coroutine::getCid() > 0) { + if (\extension_loaded('swoole') && Coroutine::getCid() > 0) { Coroutine::sleep($delayMs / 1000); } else { \usleep($delayMs * 1000); From 6cbada9c5d5e9ee2b5e2464582816cf55b6b2466 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 11 Nov 2025 23:59:10 +1300 Subject: [PATCH 11/15] Improve delete rollbacks --- src/Database/Database.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 8c8faae29..0a7d75d04 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2916,15 +2916,14 @@ public function deleteAttribute(string $collection, string $id): bool $collection->setAttribute('attributes', \array_values($attributes)); $collection->setAttribute('indexes', \array_values($indexes)); - $success = false; + $shouldRollback = false; try { if (!$this->adapter->deleteAttribute($collection->getId(), $id)) { throw new DatabaseException('Failed to delete attribute'); } - $success = true; + $shouldRollback = true; } catch (NotFoundException) { // Ignore - $success = false; } $this->updateMetadata( @@ -2938,7 +2937,7 @@ public function deleteAttribute(string $collection, string $id): bool $attribute['array'] ?? false, $attribute['required'] ?? false ), - shouldRollback: $success, + shouldRollback: $shouldRollback, operationDescription: "attribute deletion '{$id}'", silentRollback: true ); From aa1253dc6d898fb205eb85f2054c81b1e5719161 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 12 Nov 2025 00:05:12 +1300 Subject: [PATCH 12/15] Update comment --- src/Database/Database.php | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 0a7d75d04..0fcbb78c8 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1378,7 +1378,7 @@ public function create(?string $database = null): bool try { $this->trigger(self::EVENT_DATABASE_CREATE, $database); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return true; @@ -1412,7 +1412,7 @@ public function list(): array try { $this->trigger(self::EVENT_DATABASE_LIST, $databases); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return $databases; @@ -1437,7 +1437,7 @@ public function delete(?string $database = null): bool 'deleted' => $deleted ]); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } $this->cache->flush(); @@ -1610,7 +1610,7 @@ public function createCollection(string $id, array $attributes = [], array $inde try { $this->trigger(self::EVENT_COLLECTION_CREATE, $createdCollection); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return $createdCollection; @@ -1658,7 +1658,7 @@ public function updateCollection(string $id, array $permissions, bool $documentS try { $this->trigger(self::EVENT_COLLECTION_UPDATE, $collection); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return $collection; @@ -1688,7 +1688,7 @@ public function getCollection(string $id): Document try { $this->trigger(self::EVENT_COLLECTION_READ, $collection); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return $collection; @@ -1713,7 +1713,7 @@ public function listCollections(int $limit = 25, int $offset = 0): array try { $this->trigger(self::EVENT_COLLECTION_LIST, $result); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return $result; @@ -1827,7 +1827,7 @@ public function deleteCollection(string $id): bool try { $this->trigger(self::EVENT_COLLECTION_DELETE, $collection); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } } @@ -1923,13 +1923,13 @@ public function createAttribute(string $collection, string $id, string $type, in '$collection' => self::METADATA ])); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } try { $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return true; @@ -2048,13 +2048,13 @@ public function createAttributes(string $collection, array $attributes): bool '$collection' => self::METADATA ])); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } try { $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attributeDocuments); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return true; @@ -2393,7 +2393,7 @@ protected function updateAttributeMeta(string $collection, string $id, callable try { $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attributes[$index]); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return $attributes[$index]; @@ -2810,13 +2810,13 @@ public function updateAttribute(string $collection, string $id, ?string $type = '$collection' => self::METADATA ])); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } try { $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return $attribute; @@ -2951,13 +2951,13 @@ public function deleteAttribute(string $collection, string $id): bool '$collection' => self::METADATA ])); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } try { $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $attribute); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return true; @@ -3051,7 +3051,7 @@ public function renameAttribute(string $collection, string $old, string $new): b try { $this->trigger(self::EVENT_ATTRIBUTE_UPDATE, $attribute); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return $renamed; @@ -3383,7 +3383,7 @@ public function createRelationship( try { $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $relationship); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return true; @@ -3760,7 +3760,7 @@ public function deleteRelationship(string $collection, string $id): bool try { $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $relationship); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return true; @@ -3829,7 +3829,7 @@ public function renameIndex(string $collection, string $old, string $new): bool try { $this->trigger(self::EVENT_INDEX_RENAME, $indexNew); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return true; @@ -4060,7 +4060,7 @@ public function deleteIndex(string $collection, string $id): bool try { $this->trigger(self::EVENT_INDEX_DELETE, $indexDeleted); } catch (\Throwable $e) { - // Log but don't throw - event failures shouldn't fail the operation + // Ignore } return $deleted; From 4a9535e0fba4531b109b56067a1a9ff0c7a90406 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 12 Nov 2025 00:08:35 +1300 Subject: [PATCH 13/15] Simplify ex names --- src/Database/Database.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 0fcbb78c8..bbc89b694 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1600,8 +1600,8 @@ public function createCollection(string $id, array $attributes = [], array $inde if ($created) { try { $this->adapter->deleteCollection($id); - } catch (\Throwable $rollbackError) { - // Log rollback failure but continue throwing original error + } catch (\Throwable $e) { + // Ignore } } throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); @@ -3295,7 +3295,7 @@ public function createRelationship( if ($junctionCollection !== null) { try { $this->deleteCollection($junctionCollection); - } catch (\Throwable $rollbackException) { + } catch (\Throwable $e) { // Continue to throw original error } } @@ -3350,7 +3350,7 @@ public function createRelationship( $relatedCollection->setAttribute('attributes', array_filter($relatedAttributes, fn ($attr) => $attr->getId() !== $twoWayKey)); $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); }); - } catch (\Throwable $rollbackException) { + } catch (\Throwable $e) { // Continue rollback } @@ -3537,8 +3537,8 @@ public function updateRelationship( $id, $oldTwoWayKey ); - } catch (\Throwable $rollbackError) { - // Log rollback failure but continue throwing original error + } catch (\Throwable $e) { + // Ignore } } throw $e; @@ -3748,7 +3748,7 @@ public function deleteRelationship(string $collection, string $id): bool $id, $twoWayKey ); - } catch (\Throwable $rollbackError) { + } catch (\Throwable $e) { // Log rollback failure but don't throw - we're already handling an error } throw new DatabaseException('Failed to persist metadata after retries: ' . $e->getMessage()); @@ -9089,14 +9089,14 @@ private function updateMetadata( // Silent mode: swallow rollback errors try { $rollbackOperation(); - } catch (\Throwable $rollbackError) { + } catch (\Throwable $e) { // Silent rollback - errors are swallowed } } else { // Regular mode: rollback throws on failure try { $rollbackOperation(); - } catch (\Throwable $rollbackException) { + } catch (\Throwable $e) { throw new DatabaseException( "Failed to persist metadata after retries and cleanup failed for {$operationDescription}: " . $e->getMessage() . ' | Cleanup error: ' . $rollbackException->getMessage(), previous: $e From b561d0f54549e9f1f21ee6c48b1420a36ed27b7a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 12 Nov 2025 00:35:21 +1300 Subject: [PATCH 14/15] Improve delete flows --- src/Database/Database.php | 146 +++++++++++++++++++++++--------------- 1 file changed, 87 insertions(+), 59 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index bbc89b694..b16f12582 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1599,9 +1599,9 @@ public function createCollection(string $id, array $attributes = [], array $inde } catch (\Throwable $e) { if ($created) { try { - $this->adapter->deleteCollection($id); - } catch (\Throwable $e) { - // Ignore + $this->cleanupCollection($id); + } catch (\Throwable $rollbackError) { + Console::error("Failed to rollback collection '{$id}': " . $rollbackError->getMessage()); } } throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); @@ -2928,18 +2928,9 @@ public function deleteAttribute(string $collection, string $id): bool $this->updateMetadata( collection: $collection, - rollbackOperation: fn () => $this->adapter->createAttribute( - $collection->getId(), - $id, - $attribute['type'], - $attribute['size'], - $attribute['signed'] ?? false, - $attribute['array'] ?? false, - $attribute['required'] ?? false - ), - shouldRollback: $shouldRollback, - operationDescription: "attribute deletion '{$id}'", - silentRollback: true + rollbackOperation: null, + shouldRollback: false, + operationDescription: "attribute deletion '{$id}'" ); $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); @@ -3106,6 +3097,66 @@ private function cleanupAttributes( return $errors; } + /** + * Cleanup (delete) a collection with retry logic + * + * @param string $collectionId The collection ID + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupCollection( + string $collectionId, + int $maxAttempts = 3 + ): void { + $this->cleanup( + fn () => $this->adapter->deleteCollection($collectionId), + 'collection', + $collectionId, + $maxAttempts + ); + } + + /** + * Cleanup (delete) a relationship with retry logic + * + * @param string $collectionId The collection ID + * @param string $relatedCollectionId The related collection ID + * @param string $type The relationship type + * @param bool $twoWay Whether the relationship is two-way + * @param string $key The relationship key + * @param string $twoWayKey The two-way relationship key + * @param string $side The relationship side + * @param int $maxAttempts Maximum retry attempts + * @return void + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupRelationship( + string $collectionId, + string $relatedCollectionId, + string $type, + bool $twoWay, + string $key, + string $twoWayKey, + string $side = Database::RELATION_SIDE_PARENT, + int $maxAttempts = 3 + ): void { + $this->cleanup( + fn () => $this->adapter->deleteRelationship( + $collectionId, + $relatedCollectionId, + $type, + $twoWay, + $key, + $twoWayKey, + $side + ), + 'relationship', + $key, + $maxAttempts + ); + } + /** * Create a relationship attribute * @@ -3199,9 +3250,7 @@ public function createRelationship( $this->checkAttribute($collection, $relationship); $this->checkAttribute($relatedCollection, $twoWayRelationship); - - - // Track junction collection name for rollback + $junctionCollection = null; if ($type === self::RELATION_MANY_TO_MANY) { $junctionCollection = '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence(); @@ -3252,12 +3301,11 @@ public function createRelationship( ); if (!$created) { - // Rollback junction table if it was created if ($junctionCollection !== null) { try { - $this->silent(fn () => $this->deleteCollection($junctionCollection)); + $this->silent(fn () => $this->cleanupCollection($junctionCollection)); } catch (\Throwable $e) { - // Continue to throw the main error + Console::error("Failed to cleanup junction collection '{$junctionCollection}': " . $e->getMessage()); } } throw new DatabaseException('Failed to create relationship'); @@ -3278,7 +3326,7 @@ public function createRelationship( $this->rollbackAttributeMetadata($relatedCollection, [$twoWayKey]); try { - $this->adapter->deleteRelationship( + $this->cleanupRelationship( $collection->getId(), $relatedCollection->getId(), $type, @@ -3288,15 +3336,14 @@ public function createRelationship( Database::RELATION_SIDE_PARENT ); } catch (\Throwable $e) { - // Continue to rollback junction table + Console::error("Failed to cleanup relationship '{$id}': " . $e->getMessage()); } - // Rollback junction table if it was created if ($junctionCollection !== null) { try { - $this->deleteCollection($junctionCollection); + $this->cleanupCollection($junctionCollection); } catch (\Throwable $e) { - // Continue to throw original error + Console::error("Failed to cleanup junction collection '{$junctionCollection}': " . $e->getMessage()); } } @@ -3335,8 +3382,8 @@ public function createRelationship( foreach ($indexesCreated as $indexInfo) { try { $this->deleteIndex($indexInfo['collection'], $indexInfo['index']); - } catch (\Throwable $e) { - // Continue rollback + } catch (\Throwable $cleanupError) { + Console::error("Failed to cleanup index '{$indexInfo['index']}': " . $cleanupError->getMessage()); } } @@ -3350,12 +3397,13 @@ public function createRelationship( $relatedCollection->setAttribute('attributes', array_filter($relatedAttributes, fn ($attr) => $attr->getId() !== $twoWayKey)); $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); }); - } catch (\Throwable $e) { - // Continue rollback + } catch (\Throwable $cleanupError) { + Console::error("Failed to cleanup metadata for relationship '{$id}': " . $cleanupError->getMessage()); } + // Cleanup relationship try { - $this->adapter->deleteRelationship( + $this->cleanupRelationship( $collection->getId(), $relatedCollection->getId(), $type, @@ -3364,15 +3412,15 @@ public function createRelationship( $twoWayKey, Database::RELATION_SIDE_PARENT ); - } catch (\Throwable $e) { - // Continue rollback + } catch (\Throwable $cleanupError) { + Console::error("Failed to cleanup relationship '{$id}': " . $cleanupError->getMessage()); } if ($junctionCollection !== null) { try { - $this->deleteCollection($junctionCollection); - } catch (\Throwable $e) { - // Continue to throw original error + $this->cleanupCollection($junctionCollection); + } catch (\Throwable $cleanupError) { + Console::error("Failed to cleanup junction collection '{$junctionCollection}': " . $cleanupError->getMessage()); } } @@ -3739,18 +3787,6 @@ public function deleteRelationship(string $collection, string $id): bool }); }); } catch (\Throwable $e) { - try { - $this->adapter->createRelationship( - $collection->getId(), - $relatedCollection->getId(), - $type, - $twoWay, - $id, - $twoWayKey - ); - } catch (\Throwable $e) { - // Log rollback failure but don't throw - we're already handling an error - } throw new DatabaseException('Failed to persist metadata after retries: ' . $e->getMessage()); } @@ -4043,17 +4079,9 @@ public function deleteIndex(string $collection, string $id): bool $this->updateMetadata( collection: $collection, - rollbackOperation: fn () => $this->adapter->createIndex( - $collection->getId(), - $id, - $indexDeleted['type'], - $indexDeleted['attributes'], - $indexDeleted['lengths'] ?? [], - $indexDeleted['orders'] ?? [] - ), - shouldRollback: $deleted, - operationDescription: "index deletion '{$id}'", - silentRollback: true + rollbackOperation: null, + shouldRollback: false, + operationDescription: "index deletion '{$id}'" ); From b23eb463ae34120ae3bd6d93519b1bff1ccb8874 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 12 Nov 2025 00:37:52 +1300 Subject: [PATCH 15/15] Fix stan --- src/Database/Database.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index b16f12582..9f2973bcd 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1600,8 +1600,8 @@ public function createCollection(string $id, array $attributes = [], array $inde if ($created) { try { $this->cleanupCollection($id); - } catch (\Throwable $rollbackError) { - Console::error("Failed to rollback collection '{$id}': " . $rollbackError->getMessage()); + } catch (\Throwable $e) { + Console::error("Failed to rollback collection '{$id}': " . $e->getMessage()); } } throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e); @@ -3250,7 +3250,7 @@ public function createRelationship( $this->checkAttribute($collection, $relationship); $this->checkAttribute($relatedCollection, $twoWayRelationship); - + $junctionCollection = null; if ($type === self::RELATION_MANY_TO_MANY) { $junctionCollection = '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence(); @@ -9124,9 +9124,9 @@ private function updateMetadata( // Regular mode: rollback throws on failure try { $rollbackOperation(); - } catch (\Throwable $e) { + } catch (\Throwable $ex) { throw new DatabaseException( - "Failed to persist metadata after retries and cleanup failed for {$operationDescription}: " . $e->getMessage() . ' | Cleanup error: ' . $rollbackException->getMessage(), + "Failed to persist metadata after retries and cleanup failed for {$operationDescription}: " . $ex->getMessage() . ' | Cleanup error: ' . $e->getMessage(), previous: $e ); }