diff --git a/Dockerfile b/Dockerfile index a3392d45d..a340dcfdd 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,9 @@ RUN composer install \ --no-scripts \ --prefer-dist -FROM php:8.4.18-cli-alpine3.22 AS compile +FROM appwrite/utopia-base:php-8.4-0.2.1 AS compile -ENV PHP_REDIS_VERSION="6.3.0" \ - PHP_SWOOLE_VERSION="v6.1.6" \ - PHP_XDEBUG_VERSION="3.4.2" \ - PHP_MONGODB_VERSION="2.1.1" -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +ENV PHP_MONGODB_VERSION="2.1.1" RUN apk update && apk add --no-cache \ libpq \ @@ -28,8 +24,6 @@ RUN apk update && apk add --no-cache \ autoconf \ gcc \ g++ \ - git \ - brotli-dev \ linux-headers \ docker-cli \ docker-cli-compose \ @@ -48,24 +42,6 @@ RUN apk update && apk add --no-cache \ && apk del libpq-dev \ && rm -rf /var/cache/apk/* -# Redis Extension -FROM compile AS redis -RUN \ - git clone --depth 1 --branch $PHP_REDIS_VERSION https://github.com/phpredis/phpredis.git \ - && cd phpredis \ - && phpize \ - && ./configure \ - && make && make install - -## Swoole Extension -FROM compile AS swoole -RUN \ - git clone --depth 1 --branch $PHP_SWOOLE_VERSION https://github.com/swoole/swoole-src.git \ - && cd swoole-src \ - && phpize \ - && ./configure --enable-http2 \ - && make && make install - ## PCOV Extension FROM compile AS pcov RUN \ @@ -75,15 +51,6 @@ RUN \ && ./configure --enable-pcov \ && make && make install -## XDebug Extension -FROM compile AS xdebug -RUN \ - git clone --depth 1 --branch $PHP_XDEBUG_VERSION https://github.com/xdebug/xdebug && \ - cd xdebug && \ - phpize && \ - ./configure && \ - make && make install - FROM compile AS final LABEL maintainer="team@appwrite.io" @@ -93,10 +60,7 @@ ENV DEBUG=$DEBUG WORKDIR /usr/src/code -RUN echo extension=redis.so >> /usr/local/etc/php/conf.d/redis.ini -RUN echo extension=swoole.so >> /usr/local/etc/php/conf.d/swoole.ini RUN echo extension=pcov.so >> /usr/local/etc/php/conf.d/pcov.ini -RUN echo extension=xdebug.so >> /usr/local/etc/php/conf.d/xdebug.ini RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" @@ -105,10 +69,7 @@ RUN echo "opcache.enable_cli=1" >> $PHP_INI_DIR/php.ini RUN echo "memory_limit=1024M" >> $PHP_INI_DIR/php.ini COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor -COPY --from=swoole /usr/local/lib/php/extensions/no-debug-non-zts-20240924/swoole.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ -COPY --from=redis /usr/local/lib/php/extensions/no-debug-non-zts-20240924/redis.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ COPY --from=pcov /usr/local/lib/php/extensions/no-debug-non-zts-20240924/pcov.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ -COPY --from=xdebug /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/ COPY ./bin /usr/src/code/bin COPY ./src /usr/src/code/src diff --git a/src/Database/Database.php b/src/Database/Database.php index b842053de..eaf31eaa5 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -4271,11 +4271,14 @@ public function getDocument(string $collection, string $id, array $queries = [], $selections ); - try { - $cached = $this->cache->load($documentKey, self::TTL, $hashKey); - } catch (Exception $e) { - Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); - $cached = null; + $cached = null; + if (!$forUpdate) { + try { + $cached = $this->cache->load($documentKey, self::TTL, $hashKey); + } catch (Exception $e) { + Console::warning('Warning: Failed to get document from cache: ' . $e->getMessage()); + $cached = null; + } } if ($cached) { @@ -4348,7 +4351,7 @@ public function getDocument(string $collection, string $id, array $queries = [], ); // Don't save to cache if it's part of a relationship - if (empty($relationships)) { + if (!$forUpdate && empty($relationships)) { try { $this->cache->save($documentKey, $document->getArrayCopy(), $hashKey); $this->cache->save($collectionKey, 'empty', $documentKey); @@ -5598,6 +5601,12 @@ public function updateDocument(string $collection, string $id, Document $documen return $attribute['type'] === Database::VAR_RELATIONSHIP; }); + // Build attribute type map for type-safe comparison + $attributeTypes = []; + foreach ($collection->getAttribute('attributes', []) as $attr) { + $attributeTypes[$attr['$id'] ?? ''] = $attr['type'] ?? ''; + } + $shouldUpdate = false; if ($collection->getId() !== self::METADATA) { @@ -5695,6 +5704,27 @@ public function updateDocument(string $collection, string $id, Document $documen $oldValue = $old->getAttribute($key); + // Cast both values to attribute type for consistent comparison + // (e.g. cache JSON round-trip turns float 1.0 into int 1, + // and some adapters may not cast floats on read) + $attrType = $attributeTypes[$key] ?? null; + if ($attrType !== null && !($value instanceof Operator)) { + switch ($attrType) { + case self::VAR_FLOAT: + $value = \is_null($value) ? null : (float)$value; + $oldValue = \is_null($oldValue) ? null : (float)$oldValue; + break; + case self::VAR_INTEGER: + $value = \is_null($value) ? null : (int)$value; + $oldValue = \is_null($oldValue) ? null : (int)$oldValue; + break; + case self::VAR_BOOLEAN: + $value = \is_null($value) ? null : (bool)$value; + $oldValue = \is_null($oldValue) ? null : (bool)$oldValue; + break; + } + } + // If values are not equal we need to update document. if ($value !== $oldValue) { $shouldUpdate = true; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index d1241ad26..dfbdcc660 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -5834,6 +5834,7 @@ public function testSingleDocumentDateOperations(): void $doc4->setAttribute('$updatedAt', null); $doc4->setAttribute('$createdAt', null); + \usleep(2000); // Ensure updatedAt timestamp differs from creation time $updatedDoc4 = $database->updateDocument($collection, 'doc4', document: $doc4); $this->assertEquals($originalCreatedAt4, $updatedDoc4->getAttribute('$createdAt')); @@ -5921,6 +5922,8 @@ public function testSingleDocumentDateOperations(): void $newUpdatedAt = $doc11->getUpdatedAt(); + \usleep(2000); // Ensure updatedAt timestamp differs from creation time + $newDoc11 = new Document([ 'string' => 'no_dates_update', ]); @@ -7456,6 +7459,54 @@ public function testRegexInjection(): void $database->deleteCollection($collectionName); } + public function testUpdateDocumentUsesFreshForUpdateReadWhenCacheIsStale(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $collectionId = 'for_update_cache'; + $database->createCollection($collectionId); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->assertEquals(true, $database->createAttribute($collectionId, 'a', Database::VAR_STRING, 255, false)); + $this->assertEquals(true, $database->createAttribute($collectionId, 'b', Database::VAR_STRING, 255, false)); + } + + $database->createDocument($collectionId, new Document([ + '$id' => 'doc1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'a' => 'A1', + 'b' => 'B1', + ])); + + // Prime cache with initial values. + $cached = $database->getDocument($collectionId, 'doc1'); + $this->assertEquals('B1', $cached->getAttribute('b')); + + $collection = $database->getCollection($collectionId); + + // Simulate an out-of-band write that bypasses cache invalidation. + $outOfBand = $database->getAdapter()->getDocument($collection, 'doc1'); + $outOfBand->setAttribute('b', 'B2'); + $database->getAdapter()->updateDocument($collection, 'doc1', $outOfBand, true); + + // Partial update should not overwrite untouched fields with stale cached values. + $updated = $database->updateDocument($collectionId, 'doc1', new Document([ + 'a' => 'A2', + ])); + + $this->assertEquals('A2', $updated->getAttribute('a')); + $this->assertEquals('B2', $updated->getAttribute('b')); + + $fresh = $database->getDocument($collectionId, 'doc1'); + $this->assertEquals('B2', $fresh->getAttribute('b')); + + $database->deleteCollection($collectionId); + } + /** * Test ReDoS (Regular Expression Denial of Service) with timeout protection * This test verifies that ReDoS patterns either timeout properly or complete quickly,