Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions src/Executor/Promise/Adapter/SyncPromise.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ public static function runQueue(): void
while (! $q->isEmpty()) {
$task = $q->dequeue();
$task();
// Explicitly clear the task reference to help garbage collection
unset($task);
}
}

Expand All @@ -58,11 +60,16 @@ public function __construct(?callable $executor = null)
return;
}

self::getQueue()->enqueue(function () use ($executor): void {
self::getQueue()->enqueue(function () use (&$executor): void {
try {
assert(is_callable($executor));
$this->resolve($executor());
} catch (\Throwable $e) {
$this->reject($e);
} finally {
// Clear the executor reference to allow garbage collection
// of the closure and its captured context
$executor = null;
}
});
}
Expand Down Expand Up @@ -143,26 +150,25 @@ private function enqueueWaitingPromises(): void
throw new InvariantViolation('Cannot enqueue derived promises when parent is still pending');
}

$state = $this->state;
$result = $this->result;

foreach ($this->waiting as $descriptor) {
self::getQueue()->enqueue(function () use ($descriptor): void {
self::getQueue()->enqueue(static function () use ($descriptor, $state, $result): void {
[$promise, $onFulfilled, $onRejected] = $descriptor;

if ($this->state === self::FULFILLED) {
try {
$promise->resolve($onFulfilled === null ? $this->result : $onFulfilled($this->result));
} catch (\Throwable $e) {
$promise->reject($e);
}
} elseif ($this->state === self::REJECTED) {
try {
try {
if ($state === self::FULFILLED) {
$promise->resolve($onFulfilled === null ? $result : $onFulfilled($result));
} elseif ($state === self::REJECTED) {
if ($onRejected === null) {
$promise->reject($this->result);
$promise->reject($result);
} else {
$promise->resolve($onRejected($this->result));
$promise->resolve($onRejected($result));
}
} catch (\Throwable $e) {
$promise->reject($e);
}
} catch (\Throwable $e) {
$promise->reject($e);
}
});
}
Expand Down
99 changes: 99 additions & 0 deletions tests/Executor/DeferredFieldsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -668,4 +668,103 @@ private function assertPathsMatch(array $expectedPaths): void
self::assertContains($expectedPath, $this->paths, 'Missing path: ' . json_encode($expectedPath, JSON_THROW_ON_ERROR));
}
}

public function testDeferredMemoryUsage(): void
{
// Generate test data similar to the issue reproduction
$authors = [];
for ($i = 0; $i <= 100; ++$i) {
$authors[$i] = ['name' => 'Name ' . $i];
}

$books = [];
for ($i = 0; $i <= 1000; ++$i) {
$books[$i] = ['title' => 'Title ' . $i, 'authorId' => random_int(0, 100)];
}

$authorType = new ObjectType([
'name' => 'Author',
'fields' => [
'name' => [
'type' => Type::string(),
],
],
]);

$bookType = new ObjectType([
'name' => 'Book',
'fields' => [
'title' => [
'type' => Type::string(),
],
'author' => [
'type' => $authorType,
'resolve' => static fn ($rootValue): Deferred => new Deferred(static fn (): array => $authors[$rootValue['authorId']]),
],
],
]);

$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'getBooks' => [
'type' => Type::listOf($bookType),
'resolve' => static fn (): array => $books,
],
],
]);

$schema = new Schema([
'query' => $queryType,
]);

$query = Parser::parse('
{
getBooks {
title
author {
name
}
}
}
');

// Run the query multiple times to detect memory leaks
// If there's a leak, memory will grow with each iteration
$memoryMeasurements = [];

for ($iteration = 0; $iteration < 3; ++$iteration) {
gc_collect_cycles();
$memoryBefore = memory_get_usage();

$result = Executor::execute($schema, $query);

// Verify the query executed successfully
self::assertArrayNotHasKey('errors', $result->toArray());

$memoryAfter = memory_get_usage();
$memoryMeasurements[$iteration] = $memoryAfter - $memoryBefore;

// Clear result to prepare for next iteration
unset($result);
}

// With proper cleanup, memory usage should be stable across iterations
// Allow some variation (10%) but memory shouldn't grow significantly
$firstIteration = $memoryMeasurements[0];
$lastIteration = $memoryMeasurements[2];
$memoryGrowth = $lastIteration - $firstIteration;
$allowedGrowth = $firstIteration * 0.10; // 10% tolerance

self::assertLessThan(
$allowedGrowth,
$memoryGrowth,
sprintf(
'Memory leak detected: memory grew by %.2fMB across iterations (%.2fMB -> %.2fMB)',
$memoryGrowth / 1024 / 1024,
$firstIteration / 1024 / 1024,
$lastIteration / 1024 / 1024
)
);
}
}
Loading