-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
T16976 findfirst segfault memory cache limit #16988
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: 5.0.x
Are you sure you want to change the base?
Changes from all commits
728a537
cb376fd
a317abe
d7f8b2a
d98b9a0
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 |
|---|---|---|
|
|
@@ -691,9 +691,20 @@ class Query implements QueryInterface, InjectionAwareInterface | |
| } | ||
|
|
||
| /** | ||
| * Store the prepared AST in the cache | ||
| * Store the prepared AST in the cache. | ||
| * Evict oldest entries when the cache exceeds the limit to prevent | ||
| * unbounded memory growth in long-running loops with dynamic PHQL. | ||
| */ | ||
| if typeof uniqueId == "int" { | ||
| if count(self::internalPhqlCache) > 1024 { | ||
| let self::internalPhqlCache = array_slice( | ||
| self::internalPhqlCache, | ||
| -512, | ||
| null, | ||
| true | ||
| ); | ||
| } | ||
|
Member
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. Please add the 1024 and 512 as constants in this file with a docblock of what they represent
Member
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. I would change that to be 75% instead of 50%. It does reduce the internal cache but not halving it which could in theory have a bigger impact. |
||
|
|
||
| let self::internalPhqlCache[uniqueId] = irPhql; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * This file is part of the Phalcon Framework. | ||
| * | ||
| * (c) Phalcon Team <team@phalcon.io> | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE.txt | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Phalcon\Tests\Database\Db\Result\Pdo; | ||
|
|
||
| use PDO; | ||
| use PDOStatement; | ||
| use Phalcon\Db\Result\PdoResult; | ||
| use Phalcon\Tests\AbstractDatabaseTestCase; | ||
| use Phalcon\Tests\Support\Traits\DiTrait; | ||
|
|
||
| /** | ||
| * @issue https://github.com/phalcon/cphalcon/issues/16955 | ||
|
Member
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. This one goes in the test i.e. method |
||
| */ | ||
| final class DestructTest extends AbstractDatabaseTestCase | ||
| { | ||
| use DiTrait; | ||
|
|
||
| public function setUp(): void | ||
| { | ||
| $this->setNewFactoryDefault(); | ||
| $this->setDatabase(); | ||
| } | ||
|
|
||
| public function tearDown(): void | ||
| { | ||
| $this->tearDownDatabase(); | ||
| } | ||
|
|
||
| /** | ||
| * Tests that PdoResult::__destruct calls closeCursor and nullifies resources. | ||
| * | ||
| * @author Phalcon Team <team@phalcon.io> | ||
|
Member
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. This one is your name - we didn't do this work.
Member
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. Same with the rest of the tests in this file |
||
| * @since 2026-05-05 | ||
| * | ||
| * @group mysql | ||
| * @group pgsql | ||
| * @group sqlite | ||
| */ | ||
| public function testDbResultPdoDestructCleansUpResources(): void | ||
| { | ||
| $db = $this->getService('db'); | ||
|
|
||
| $result = $db->query("SELECT 1 AS one"); | ||
|
|
||
| $this->assertInstanceOf(PdoResult::class, $result); | ||
|
|
||
| $pdoStatement = $this->getProtectedProperty($result, 'pdoStatement'); | ||
| $this->assertNotNull( | ||
| $pdoStatement, | ||
| 'pdoStatement should be set before destruction' | ||
| ); | ||
|
|
||
| unset($result); | ||
|
|
||
| gc_collect_cycles(); | ||
|
|
||
| $this->assertTrue( | ||
| true, | ||
| 'PdoResult should be destructed without errors after closeCursor' | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Tests that PdoResult can be created and garbage collected in a tight loop | ||
| * without accumulating open statements. | ||
| * | ||
| * @author Phalcon Team <team@phalcon.io> | ||
| * @since 2026-05-05 | ||
| * | ||
| * @group mysql | ||
| * @group pgsql | ||
| * @group sqlite | ||
| */ | ||
| public function testDbResultPdoDestructHandlesHighIteration(): void | ||
| { | ||
| $db = $this->getService('db'); | ||
| $limit = 500; | ||
|
|
||
| $memBefore = memory_get_usage(true); | ||
|
|
||
| for ($i = 0; $i < $limit; $i++) { | ||
| $result = $db->query("SELECT " . $i . " AS val"); | ||
| $row = $result->fetch(); | ||
| unset($result); | ||
| } | ||
|
|
||
| gc_collect_cycles(); | ||
|
|
||
| $memAfter = memory_get_usage(true); | ||
| $memGrowth = $memAfter - $memBefore; | ||
|
|
||
| $this->assertLessThan( | ||
| 5 * 1024 * 1024, | ||
| $memGrowth, | ||
| 'Memory growth should be under 5MB after ' . $limit . ' iterations. Growth: ' | ||
| . round($memGrowth / 1024 / 1024, 2) . 'MB' | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Tests that closeCursor is safe to call on an already-freed statement. | ||
| * | ||
| * @author Phalcon Team <team@phalcon.io> | ||
| * @since 2026-05-05 | ||
| * | ||
| * @group mysql | ||
| * @group pgsql | ||
| * @group sqlite | ||
| */ | ||
| public function testDbResultPdoDestructSafeOnDoubleDestruction(): void | ||
| { | ||
| $db = $this->getService('db'); | ||
|
|
||
| $result = $db->query("SELECT 1 AS one"); | ||
| $stmt = $this->getProtectedProperty($result, 'pdoStatement'); | ||
|
|
||
| $this->assertInstanceOf( | ||
| PDOStatement::class, | ||
| $stmt, | ||
| 'Internal pdoStatement should be a PDOStatement' | ||
| ); | ||
|
|
||
| $stmt->closeCursor(); | ||
|
|
||
| unset($result); | ||
|
|
||
| gc_collect_cycles(); | ||
|
|
||
| $this->assertTrue( | ||
| true, | ||
| 'Double closeCursor (manual + __destruct) should not throw' | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * This file is part of the Phalcon Framework. | ||
| * | ||
| * (c) Phalcon Team <team@phalcon.io> | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE.txt | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Phalcon\Tests\Database\Mvc\Model; | ||
|
|
||
| use PDO; | ||
| use Phalcon\Mvc\Model\Query; | ||
| use Phalcon\Tests\AbstractDatabaseTestCase; | ||
| use Phalcon\Tests\Support\Migrations\InvoicesMigration; | ||
| use Phalcon\Tests\Support\Models\Invoices; | ||
| use Phalcon\Tests\Support\Traits\DiTrait; | ||
|
|
||
| use function memory_get_usage; | ||
| use function gc_collect_cycles; | ||
|
|
||
| /** | ||
| * Tests that findFirst in a high-iteration loop does not cause | ||
| * unbounded memory growth or segmentation faults. | ||
| * | ||
| * @issue https://github.com/phalcon/cphalcon/issues/16955 | ||
| * | ||
| * @group phql | ||
| */ | ||
| final class FindFirstMemoryTest extends AbstractDatabaseTestCase | ||
| { | ||
| use DiTrait; | ||
|
|
||
| public function setUp(): void | ||
| { | ||
| $this->setNewFactoryDefault(); | ||
| $this->setDatabase(); | ||
|
|
||
| /** @var PDO $connection */ | ||
| $connection = self::getConnection(); | ||
| $migration = new InvoicesMigration($connection); | ||
|
|
||
| for ($i = 1; $i <= 200; $i++) { | ||
| $migration->insert($i, null, 0, 'title-' . $i); | ||
| } | ||
|
|
||
| Query::clean(); | ||
| } | ||
|
|
||
| public function tearDown(): void | ||
| { | ||
| Query::clean(); | ||
| $this->tearDownDatabase(); | ||
| } | ||
|
|
||
| /** | ||
| * Tests findFirst with numeric PK in a loop does not leak memory. | ||
| * | ||
| * Since findFirst($id) uses bound parameter (:APK0:), the PHQL is | ||
| * identical per call — only 1 cache entry per model. This verifies | ||
| * that the ORM properly releases resources between iterations. | ||
| * | ||
| * @author Phalcon Team <team@phalcon.io> | ||
| * @since 2026-05-05 | ||
| * | ||
| * @group mysql | ||
| * @group pgsql | ||
| * @group sqlite | ||
| */ | ||
| public function testMvcModelFindFirstHighIterationWithNumericPk(): void | ||
| { | ||
| $iterations = 1000; | ||
| $memBefore = memory_get_usage(true); | ||
|
|
||
| for ($i = 1; $i <= $iterations; $i++) { | ||
| $id = (($i - 1) % 200) + 1; | ||
| $invoice = Invoices::findFirst($id); | ||
| unset($invoice); | ||
| } | ||
|
|
||
| gc_collect_cycles(); | ||
|
|
||
| $memAfter = memory_get_usage(true); | ||
| $memGrowth = $memAfter - $memBefore; | ||
|
|
||
| $this->assertLessThan( | ||
| 10 * 1024 * 1024, | ||
| $memGrowth, | ||
| 'Memory growth should be under 10MB after ' . $iterations | ||
| . ' findFirst iterations. Growth: ' | ||
| . round($memGrowth / 1024 / 1024, 2) . 'MB' | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Tests findFirst with unique conditions per iteration triggers cache eviction. | ||
| * | ||
| * Each iteration produces a unique PHQL string, which would previously | ||
| * cause unbounded cache growth. The cache eviction mechanism should keep | ||
| * memory bounded. | ||
| * | ||
| * @author Phalcon Team <team@phalcon.io> | ||
| * @since 2026-05-05 | ||
| * | ||
| * @group mysql | ||
| * @group pgsql | ||
| * @group sqlite | ||
| */ | ||
| public function testMvcModelFindFirstHighIterationWithUniqueConditions(): void | ||
| { | ||
| $iterations = 500; | ||
| $memBefore = memory_get_usage(true); | ||
|
|
||
| for ($i = 1; $i <= $iterations; $i++) { | ||
| $id = (($i - 1) % 200) + 1; | ||
|
|
||
| Invoices::findFirst( | ||
| [ | ||
| 'conditions' => 'inv_id = ' . $id . ' AND inv_status_flag = 0', | ||
| ] | ||
| ); | ||
| } | ||
|
|
||
| gc_collect_cycles(); | ||
|
|
||
| $memAfter = memory_get_usage(true); | ||
| $memGrowth = $memAfter - $memBefore; | ||
|
|
||
| $this->assertLessThan( | ||
| 10 * 1024 * 1024, | ||
| $memGrowth, | ||
| 'Memory growth should be under 10MB after ' . $iterations | ||
| . ' unique-condition findFirst iterations. Growth: ' | ||
| . round($memGrowth / 1024 / 1024, 2) . 'MB' | ||
| ); | ||
| } | ||
| } |
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.
There has to be some code in the
catchbranch to alert the developer that an exception has happened. The way this is now, it will not only work in this case (fixing this bug) but it will also suppress any other exception that might happen unrelated to this fix.