diff --git a/app/config/errors.php b/app/config/errors.php index 3fc3496086..6337c3205a 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -566,6 +566,12 @@ return [ 'code' => 404, ], + Exception::EXECUTION_IN_PROGRESS => [ + 'name' => Exception::EXECUTION_IN_PROGRESS, + 'description' => 'Can\'t delete ongoing execution. Please wait for execution to finish before deleting it.', + 'code' => 400, + ], + /** Databases */ Exception::DATABASE_NOT_FOUND => [ 'name' => Exception::DATABASE_NOT_FOUND, diff --git a/app/controllers/api/functions.php b/app/controllers/api/functions.php index 4aaacd5a0c..d2a1790d94 100644 --- a/app/controllers/api/functions.php +++ b/app/controllers/api/functions.php @@ -11,6 +11,7 @@ use Appwrite\Event\Validator\FunctionEvent; use Appwrite\Extend\Exception; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Messaging\Adapter\Realtime; +use Appwrite\Platform\Tasks\ScheduleExecutions; use Appwrite\Task\Validator\Cron; use Appwrite\Utopia\Database\Validator\CustomId; use Appwrite\Utopia\Database\Validator\Queries\Deployments; @@ -1725,7 +1726,7 @@ App::post('/v1/functions/:functionId/executions') 'deploymentInternalId' => $deployment->getInternalId(), 'deploymentId' => $deployment->getId(), 'trigger' => (!is_null($scheduledAt)) ? 'schedule' : 'http', - 'status' => $status, // waiting / processing / completed / failed + 'status' => $status, // waiting / processing / completed / failed / scheduled 'responseStatusCode' => 0, 'responseHeaders' => [], 'requestPath' => $path, @@ -1771,7 +1772,7 @@ App::post('/v1/functions/:functionId/executions') $dbForConsole->createDocument('schedules', new Document([ 'region' => System::getEnv('_APP_REGION', 'default'), - 'resourceType' => 'execution', + 'resourceType' => ScheduleExecutions::getSupportedResource(), 'resourceId' => $execution->getId(), 'resourceInternalId' => $execution->getInternalId(), 'resourceUpdatedAt' => DateTime::now(), @@ -2040,6 +2041,74 @@ App::get('/v1/functions/:functionId/executions/:executionId') $response->dynamic($execution, Response::MODEL_EXECUTION); }); +App::delete('/v1/functions/:functionId/executions/:executionId') + ->groups(['api', 'functions']) + ->desc('Delete execution') + ->label('scope', 'execution.write') + ->label('event', 'functions.[functionId].executions.[executionId].delete') + ->label('audits.event', 'executions.delete') + ->label('audits.resource', 'function/{request.functionId}') + ->label('sdk.auth', [APP_AUTH_TYPE_KEY]) + ->label('sdk.namespace', 'functions') + ->label('sdk.method', 'deleteExecution') + ->label('sdk.description', '/docs/references/functions/delete-execution.md') + ->label('sdk.response.code', Response::STATUS_CODE_NOCONTENT) + ->label('sdk.response.model', Response::MODEL_NONE) + ->param('functionId', '', new UID(), 'Function ID.') + ->param('executionId', '', new UID(), 'Execution ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForConsole') + ->inject('queueForEvents') + ->action(function (string $functionId, string $executionId, Response $response, Database $dbForProject, Database $dbForConsole, Event $queueForEvents) { + $function = $dbForProject->getDocument('functions', $functionId); + + if ($function->isEmpty()) { + throw new Exception(Exception::FUNCTION_NOT_FOUND); + } + + $execution = $dbForProject->getDocument('executions', $executionId); + if ($execution->isEmpty()) { + throw new Exception(Exception::EXECUTION_NOT_FOUND); + } + + if ($execution->getAttribute('functionId') !== $function->getId()) { + throw new Exception(Exception::EXECUTION_NOT_FOUND); + } + $status = $execution->getAttribute('status'); + + if (!in_array($status, ['completed', 'failed', 'scheduled'])) { + throw new Exception(Exception::EXECUTION_IN_PROGRESS); + } + + if (!$dbForProject->deleteDocument('executions', $execution->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove execution from DB'); + } + + if ($status === 'scheduled') { + $schedule = $dbForConsole->findOne('schedules', [ + Query::equal('resourceId', [$execution->getId()]), + Query::equal('resourceType', [ScheduleExecutions::getSupportedResource()]), + Query::equal('active', [true]), + ]); + + if ($schedule && !$schedule->isEmpty()) { + $schedule + ->setAttribute('resourceUpdatedAt', DateTime::now()) + ->setAttribute('active', false); + + Authorization::skip(fn () => $dbForConsole->updateDocument('schedules', $schedule->getId(), $schedule)); + } + } + + $queueForEvents + ->setParam('functionId', $function->getId()) + ->setParam('executionId', $execution->getId()) + ->setPayload($response->output($execution, Response::MODEL_EXECUTION)); + + $response->noContent(); + }); + // Variables App::post('/v1/functions/:functionId/variables') diff --git a/docs/references/functions/delete-execution.md b/docs/references/functions/delete-execution.md new file mode 100644 index 0000000000..d7cad98ac1 --- /dev/null +++ b/docs/references/functions/delete-execution.md @@ -0,0 +1 @@ +Delete a function execution by its unique ID. diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 054e455bb5..dbc7d9425e 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -168,6 +168,7 @@ class Exception extends \Exception /** Execution */ public const EXECUTION_NOT_FOUND = 'execution_not_found'; + public const EXECUTION_IN_PROGRESS = 'execution_in_progress'; /** Databases */ public const DATABASE_NOT_FOUND = 'database_not_found'; diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 09196d0272..00ba91aec9 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -454,7 +454,6 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(200, $functionDetails['headers']['status-code']); $this->assertEquals('cli', $functionDetails['body']['type']); - } /** @@ -671,12 +670,16 @@ class FunctionsCustomServerTest extends Scope /** * Test search queries */ - $function = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(), [ - 'search' => $data['functionId'] - ])); + $function = $this->client->call( + Client::METHOD_GET, + '/functions/' . $data['functionId'] . '/deployments', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(), [ + 'search' => $data['functionId'] + ]) + ); $this->assertEquals($function['headers']['status-code'], 200); $this->assertEquals(3, $function['body']['total']); @@ -732,12 +735,16 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals($function['headers']['status-code'], 200); $this->assertCount(0, $function['body']['deployments']); - $function = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(), [ - 'search' => 'Test' - ])); + $function = $this->client->call( + Client::METHOD_GET, + '/functions/' . $data['functionId'] . '/deployments', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(), [ + 'search' => 'Test' + ]) + ); $this->assertEquals($function['headers']['status-code'], 200); $this->assertEquals(3, $function['body']['total']); @@ -745,12 +752,16 @@ class FunctionsCustomServerTest extends Scope $this->assertCount(3, $function['body']['deployments']); $this->assertEquals($function['body']['deployments'][0]['$id'], $data['deploymentId']); - $function = $this->client->call(Client::METHOD_GET, '/functions/' . $data['functionId'] . '/deployments', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(), [ - 'search' => 'php-8.0' - ])); + $function = $this->client->call( + Client::METHOD_GET, + '/functions/' . $data['functionId'] . '/deployments', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(), [ + 'search' => 'php-8.0' + ]) + ); $this->assertEquals($function['headers']['status-code'], 200); $this->assertEquals(3, $function['body']['total']); @@ -977,6 +988,85 @@ class FunctionsCustomServerTest extends Scope return $data; } + + /** + * @depends testGetExecution + */ + public function testDeleteExecution($data): array + { + /** + * Test for SUCCESS + */ + $execution = $this->client->call(Client::METHOD_DELETE, '/functions/' . $data['functionId'] . '/executions/' . $data['executionId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $execution['headers']['status-code']); + $this->assertEmpty($execution['body']); + + $execution = $this->client->call(Client::METHOD_DELETE, '/functions/' . $data['functionId'] . '/executions/' . $data['executionId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $execution['headers']['status-code']); + $this->assertStringContainsString('Execution with the requested ID could not be found', $execution['body']['message']); + + /** + * Test for FAILURE + */ + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $data['functionId'] . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'async' => true, + ]); + + $executionId = $execution['body']['$id'] ?? ''; + $this->assertEquals(202, $execution['headers']['status-code']); + + $execution = $this->client->call(Client::METHOD_DELETE, '/functions/' . $data['functionId'] . '/executions/' . $executionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(400, $execution['headers']['status-code']); + $this->assertStringContainsString('execution_in_progress', $execution['body']['type']); + $this->assertStringContainsString('Can\'t delete ongoing execution.', $execution['body']['message']); + + return $data; + } + + /** + * @depends testGetExecution + */ + public function testDeleteScheduledExecution($data): array + { + $futureTime = (new \DateTime())->add(new \DateInterval('PT10H'))->format('Y-m-d H:i:s'); + + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $data['functionId'] . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'async' => true, + 'scheduledAt' => $futureTime, + ]); + + $executionId = $execution['body']['$id'] ?? ''; + $this->assertEquals(202, $execution['headers']['status-code']); + sleep(5); + $execution = $this->client->call(Client::METHOD_DELETE, '/functions/' . $data['functionId'] . '/executions/' . $executionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $execution['headers']['status-code']); + $this->assertEmpty($execution['body']); + + return $data; + } + /** * @depends testGetExecution */ @@ -1158,10 +1248,10 @@ class FunctionsCustomServerTest extends Scope public function provideCustomExecutions(): array { return [ - [ 'folder' => 'php-fn', 'name' => 'php-8.0', 'entrypoint' => 'index.php', 'runtimeName' => 'PHP', 'runtimeVersion' => '8.0' ], - [ 'folder' => 'node', 'name' => 'node-18.0', 'entrypoint' => 'index.js', 'runtimeName' => 'Node.js', 'runtimeVersion' => '18.0' ], - [ 'folder' => 'python', 'name' => 'python-3.9', 'entrypoint' => 'main.py', 'runtimeName' => 'Python', 'runtimeVersion' => '3.9' ], - [ 'folder' => 'ruby', 'name' => 'ruby-3.1', 'entrypoint' => 'main.rb', 'runtimeName' => 'Ruby', 'runtimeVersion' => '3.1' ], + ['folder' => 'php-fn', 'name' => 'php-8.0', 'entrypoint' => 'index.php', 'runtimeName' => 'PHP', 'runtimeVersion' => '8.0'], + ['folder' => 'node', 'name' => 'node-18.0', 'entrypoint' => 'index.js', 'runtimeName' => 'Node.js', 'runtimeVersion' => '18.0'], + ['folder' => 'python', 'name' => 'python-3.9', 'entrypoint' => 'main.py', 'runtimeName' => 'Python', 'runtimeVersion' => '3.9'], + ['folder' => 'ruby', 'name' => 'ruby-3.1', 'entrypoint' => 'main.rb', 'runtimeName' => 'Ruby', 'runtimeVersion' => '3.1'], // Swift and Dart disabled as it's very slow. // [ 'folder' => 'dart', 'name' => 'dart-2.15', 'entrypoint' => 'main.dart', 'runtimeName' => 'Dart', 'runtimeVersion' => '2.15' ], // [ 'folder' => 'swift', 'name' => 'swift-5.5', 'entrypoint' => 'index.swift', 'runtimeName' => 'Swift', 'runtimeVersion' => '5.5' ],