-
Notifications
You must be signed in to change notification settings - Fork 59
Metadata apply #956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Metadata apply #956
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2357,13 +2357,12 @@ public function createAttribute(string $collection, string $id, string $type, in | |
| } | ||
| } | ||
|
|
||
| $collection->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); | ||
|
|
||
| $this->updateMetadata( | ||
| collection: $collection, | ||
| rollbackOperation: fn () => $this->cleanupAttribute($collection->getId(), $id), | ||
| shouldRollback: $created, | ||
| operationDescription: "attribute creation '{$id}'" | ||
| operationDescription: "attribute creation '{$id}'", | ||
| apply: fn (Document $metadata) => $this->appendMetadataAttribute($metadata, $attribute) | ||
| ); | ||
|
|
||
| $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); | ||
|
|
@@ -2560,16 +2559,17 @@ public function createAttributes(string $collection, array $attributes): bool | |
| } | ||
| } | ||
|
|
||
| foreach ($attributeDocuments as $attributeDocument) { | ||
| $collection->setAttribute('attributes', $attributeDocument, Document::SET_TYPE_APPEND); | ||
| } | ||
|
|
||
| $this->updateMetadata( | ||
| collection: $collection, | ||
| rollbackOperation: fn () => $this->cleanupAttributes($collection->getId(), $attributeDocuments), | ||
| shouldRollback: $created, | ||
| operationDescription: 'attributes creation', | ||
| rollbackReturnsErrors: true | ||
| rollbackReturnsErrors: true, | ||
| apply: function (Document $metadata) use ($attributeDocuments) { | ||
| foreach ($attributeDocuments as $attributeDocument) { | ||
| $this->appendMetadataAttribute($metadata, $attributeDocument); | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); | ||
|
|
@@ -4787,13 +4787,12 @@ public function createIndex(string $collection, string $id, string $type, array | |
| } | ||
| } | ||
|
|
||
| $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); | ||
|
|
||
| $this->updateMetadata( | ||
| collection: $collection, | ||
| rollbackOperation: fn () => $this->cleanupIndex($collection->getId(), $id), | ||
| shouldRollback: $created, | ||
| operationDescription: "index creation '{$id}'" | ||
| operationDescription: "index creation '{$id}'", | ||
| apply: fn (Document $metadata) => $this->appendMetadataIndex($metadata, $index) | ||
| ); | ||
|
|
||
| $this->trigger(self::EVENT_INDEX_CREATE, $index); | ||
|
|
@@ -10922,6 +10921,7 @@ private function cleanupIndex( | |
| * @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) | ||
| * @param callable(Document): void|null $apply Applies the change to a fresh, locked copy of the metadata row | ||
| * @return void | ||
| * @throws DatabaseException If metadata persistence fails after all retries | ||
| */ | ||
|
|
@@ -10931,12 +10931,15 @@ private function updateMetadata( | |
| bool $shouldRollback, | ||
| string $operationDescription = 'operation', | ||
| bool $rollbackReturnsErrors = false, | ||
| bool $silentRollback = false | ||
| bool $silentRollback = false, | ||
| ?callable $apply = null | ||
| ): void { | ||
| try { | ||
| if ($collection->getId() !== self::METADATA) { | ||
| $this->withRetries( | ||
| fn () => $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) | ||
| fn () => $apply === null | ||
| ? $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)) | ||
| : $this->rebaseMetadata($collection->getId(), $apply) | ||
| ); | ||
| } | ||
| } catch (\Throwable $e) { | ||
|
|
@@ -10978,6 +10981,93 @@ private function updateMetadata( | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Apply a change to a collection's metadata row inside the transaction that | ||
| * locks it, rather than writing back a copy read earlier. | ||
| * | ||
| * The caller's copy is read before the schema change and can come from | ||
| * cache, so writing it whole drops any entry another writer added in | ||
| * between — leaving a column whose metadata never mentions it. Re-reading | ||
| * under `FOR UPDATE` here serializes the change against every other writer | ||
| * of the row, in this process or another, with no distributed lock. It | ||
| * cannot wrap the schema change too: MySQL implicitly commits at DDL, which | ||
| * would release the lock mid-call. | ||
| * | ||
| * On an adapter reporting no update-lock support (Redis, Memory, Mongo) the | ||
| * read emits no lock and the transaction gives no cross-process isolation, | ||
| * so this narrows the window to the read-write pair rather than closing it. | ||
| * That is still strictly better than writing back a copy taken before the | ||
| * schema change: the read cannot be served from cache. Serializing those | ||
| * adapters would need an advisory lock, which this class does not own. | ||
| * | ||
| * $apply must be idempotent — withTransaction() and withRetries() can both | ||
| * run it again on a fresh copy. | ||
| * | ||
| * @param callable(Document): void $apply | ||
| * @throws DatabaseException | ||
| */ | ||
| private function rebaseMetadata(string $collectionId, callable $apply): void | ||
| { | ||
| $this->withTransaction(function () use ($collectionId, $apply) { | ||
| $collection = $this->authorization->skip(fn () => $this->silent( | ||
| fn () => $this->getDocument(self::METADATA, $collectionId, forUpdate: true) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On Redis and non-replica-set MongoDB, Knowledge Base Used: Prompt To Fix With AIThis is a comment left during a code review.
Path: src/Database/Database.php
Line: 11014
Comment:
**Locking Is Adapter-Dependent**
On Redis and non-replica-set MongoDB, `forUpdate: true` provides no lock and the transaction wrapper provides no write isolation. Concurrent attribute or index creations can therefore read the same metadata row and overwrite one another. Both operations can report success while the last metadata write removes the other operation's entry, leaving document validation out of sync with the adapter schema.
**Knowledge Base Used:**
- [Transactions, retries, and caching](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/utopia-php/database/-/docs/transactions-and-cache.md)
- [Collection schema management](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/utopia-php/database/-/docs/collection-schema-management.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, getSupportForUpdateLock() returns false on Redis/Memory/Mongo, so there's no lock there. Not a regression.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That’s fair. This change is intended to prevent stale cached metadata from overwriting concurrent updates on adapters that support |
||
| )); | ||
|
|
||
| if ($collection->isEmpty()) { | ||
| throw new NotFoundException('Collection not found'); | ||
| } | ||
|
|
||
| $apply($collection); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Revalidate collection limits before the rebased append. Line 11021 applies the addition after the schema DDL has completed, but it does not validate the fresh locked metadata row. If a collection is at its attribute, width, or index limit minus one, two writers can both pass the earlier stale validation. Both physical changes then succeed, and both rebased appends persist. The collection exceeds its configured limit. Validate the candidate locked metadata before the append. If it exceeds a limit, throw so 🤖 Prompt for AI Agents |
||
|
|
||
| $this->silent(fn () => $this->updateDocument(self::METADATA, $collectionId, $collection)); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Append an attribute to a collection's metadata list, leaving the list | ||
| * alone when it already holds the key (keys are case-insensitive). | ||
| * | ||
| * Converging rather than throwing keeps createAttribute()'s existing policy | ||
| * for a key another writer got to first — it already suppresses the | ||
| * adapter's DuplicateException for a column that exists in the schema only | ||
| * — and keeps the entry describing that column instead of replacing it with | ||
| * this caller's spec. | ||
| */ | ||
| private function appendMetadataAttribute(Document $collection, Document $attribute): void | ||
| { | ||
| $key = \strtolower($attribute->getAttribute('key', $attribute->getId())); | ||
|
|
||
| /** @var array<Document> $attributes */ | ||
| $attributes = $collection->getAttribute('attributes', []); | ||
|
|
||
| foreach ($attributes as $existing) { | ||
| if (\strtolower($existing->getAttribute('key', $existing->getId())) === $key) { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| $collection->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); | ||
| } | ||
|
|
||
| /** | ||
| * Append an index to a collection's metadata list, leaving the list alone | ||
| * when it already holds the ID (IDs are case-insensitive). Converges for | ||
| * the same reason as appendMetadataAttribute(). | ||
| */ | ||
| private function appendMetadataIndex(Document $collection, Document $index): void | ||
| { | ||
| /** @var array<Document> $indexes */ | ||
| $indexes = $collection->getAttribute('indexes', []); | ||
|
|
||
| foreach ($indexes as $existing) { | ||
| if (\strtolower($existing->getId()) === \strtolower($index->getId())) { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| $collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND); | ||
| } | ||
|
|
||
| /** | ||
| * Rollback metadata state by removing specified attributes from collection | ||
| * | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |
|
|
||
| use Exception; | ||
| use Throwable; | ||
| use Utopia\Cache\Adapter\None as NoneCache; | ||
| use Utopia\Cache\Cache; | ||
| use Utopia\Database\Database; | ||
| use Utopia\Database\DateTime; | ||
| use Utopia\Database\Document; | ||
|
|
@@ -31,6 +33,19 @@ private function createRandomString(int $length = 10): string | |
| return \substr(\bin2hex(\random_bytes(\max(1, \intval(($length + 1) / 2)))), 0, $length); | ||
| } | ||
|
|
||
| /** | ||
| * Attribute keys as the collection's metadata row lists them. | ||
| * | ||
| * @return array<string> | ||
| */ | ||
| private function metadataAttributeKeys(Database $database, string $collection): array | ||
| { | ||
| return \array_map( | ||
| fn (Document $attribute) => $attribute->getAttribute('key', $attribute->getId()), | ||
| $database->getCollection($collection)->getAttribute('attributes', []) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Using phpunit dataProviders to check that all these combinations of types/defaults throw exceptions | ||
| * https://phpunit.de/manual/3.7/en/writing-tests-for-phpunit.html#writing-tests-for-phpunit.data-providers | ||
|
|
@@ -2608,4 +2623,63 @@ public function testStringTypeAttributes(): void | |
| $updatedDoc = $database->getDocument('stringTypes', 'doc1'); | ||
| $this->assertEquals('Updated varchar value', $updatedDoc->getAttribute('varchar_field')); | ||
| } | ||
|
|
||
| /** | ||
| * A peer process adds an attribute while this process still holds the | ||
| * collection as it was before that write — the copy it would otherwise | ||
| * write back whole. The peer's attribute has to survive in the metadata | ||
| * list, not only as a column in the physical schema. | ||
| */ | ||
| public function testCreateAttributeConcurrentlyKeepsPeerAttribute(): void | ||
| { | ||
| /** @var Database $database */ | ||
| $database = $this->getDatabase(); | ||
|
|
||
| $collection = 'concurrentAttribute'; | ||
|
|
||
| // A peer process: same database, its own cache, so its writes do not | ||
| // purge the copy this process is about to read. | ||
| $peer = (new Database($database->getAdapter(), new Cache(new NoneCache()))) | ||
| ->setAuthorization(self::$authorization); | ||
|
|
||
| $database->createCollection($collection, [ | ||
| new Document([ | ||
| '$id' => ID::custom('first'), | ||
| 'type' => Database::VAR_STRING, | ||
| 'size' => 32, | ||
| 'required' => false, | ||
| ]), | ||
| ], permissions: [ | ||
| Permission::read(Role::any()), | ||
| Permission::create(Role::any()), | ||
| ]); | ||
|
|
||
| // Read it once, so this process's copy predates the peer's write. | ||
| $before = $this->metadataAttributeKeys($database, $collection); | ||
|
Comment on lines
+2657
to
+2658
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🤖 get_repo_knowledge executed:
Length of output: 1095 🏁 Script executed: #!/bin/bash
set -eu
file="tests/e2e/Adapter/Scopes/AttributeTests.php"
printf '%s\n' '--- target file ---'
sed -n '2580,2725p' "$file"
printf '%s\n' '--- cache and fixture bindings ---'
rg -n -C 3 'function (getDatabase|metadataAttributeKeys)|getDatabase\(|metadataAttributeKeys|NoneCache|Cache' tests/e2e "$file" tests src 2>/dev/null | head -n 500Repository: utopia-php/database Length of output: 41710 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- test declaration and helper ---'
sed -n '1,75p' tests/e2e/Adapter/Scopes/AttributeTests.php
printf '%s\n' '--- base classes and getDatabase declarations ---'
rg -n -C 8 'class Base|function getDatabase\(\)' tests/e2e tests | head -n 400
printf '%s\n' '--- composer cache dependency ---'
rg -n -C 4 'utopia-php/cache|Utopia\\Cache' composer.json composer.lock tests/e2e | head -n 300Repository: utopia-php/database Length of output: 40450 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- all Base subclasses ---'
rg -l 'extends Base' tests/e2e/Adapter
printf '%s\n' '--- database construction and cache wiring in those fixtures ---'
for f in $(rg -l 'extends Base' tests/e2e/Adapter); do
echo "--- $f"
rg -n -C 5 'new Cache|new Database|new RedisAdapter|NoneCache|RedisAdapter' "$f" | head -n 120
done
printf '%s\n' '--- cache package source availability ---'
find vendor -path '*utopia*cache*' -type f 2>/dev/null | head -n 20 || trueRepository: utopia-php/database Length of output: 17529 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- shared Redis fixture ---'
sed -n '1,115p' tests/e2e/Adapter/SharedTables/RedisTest.php
printf '%s\n' '--- None cache implementation ---'
find vendor/utopia-php/cache -path '*Adapter*None*' -type f -print -exec sed -n '1,180p' {} \;
printf '%s\n' '--- Cache read/write behavior ---'
rg -n -C 5 'function (get|set|has|delete)|class Cache' vendor/utopia-php/cache/src vendor/utopia-php/cache/lib 2>/dev/null | head -n 300Repository: utopia-php/database Length of output: 25842 Make the stale-read precondition deterministic.
Use a retaining cache for the primary instance, or assert that the second writer uses the pre-peer metadata snapshot. 🤖 Prompt for AI Agents |
||
| $this->assertContains('first', $before); | ||
|
|
||
| $this->assertTrue($peer->createAttribute($collection, 'peer', Database::VAR_STRING, 32, false)); | ||
| $this->assertTrue($database->createAttribute($collection, 'mine', Database::VAR_STRING, 32, false)); | ||
|
|
||
| $this->assertEqualsCanonicalizing( | ||
| [...$before, 'peer', 'mine'], | ||
| $this->metadataAttributeKeys($peer, $collection), | ||
| 'Peer attribute is missing from the collection metadata' | ||
| ); | ||
|
|
||
| // The list has to match the schema, so a write naming every attribute | ||
| // must keep every value: an attribute absent from metadata is dropped. | ||
| $document = $database->createDocument($collection, new Document([ | ||
| '$id' => ID::custom('row'), | ||
| '$permissions' => [Permission::read(Role::any())], | ||
| 'first' => 'a', | ||
| 'peer' => 'b', | ||
| 'mine' => 'c', | ||
| ])); | ||
|
|
||
| $this->assertSame('b', $document->getAttribute('peer')); | ||
| $this->assertSame('c', $document->getAttribute('mine')); | ||
|
|
||
| $this->assertTrue($database->deleteCollection($collection)); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new delta-based rebase applies only to creation paths. Other schema mutations, such as attribute updates and index deletions, still pass a previously loaded collection document through the null-
applybranch. If one of these operations reads metadata before a concurrent create commits but persists afterward,updateDocument()merges its stale attributes or indexes array over the fresh row. Both operations can succeed while the newly created schema entry disappears from metadata.Knowledge Base Used:
Prompt To Fix With AI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct, and intentional scope here. Still on the old path: updateAttribute, updateAttributeMeta, updateIndexMeta, deleteAttribute, renameAttribute, renameIndex, deleteIndex plus updateCollection, which writes _metadata directly and never goes through updateMetadata(), and createRelationship/deleteRelationship, which need a two-row rebase. Follow-up PR.