diff --git a/app/controllers/general.php b/app/controllers/general.php index 42e26b6fbe..ad6b61d6b8 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -57,6 +57,7 @@ Config::setParam('cookieSamesite', Response::COOKIE_SAMESITE_NONE); function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Event $queueForEvents, StatsUsage $queueForStatsUsage, Func $queueForFunctions, Executor $executor, Reader $geodb, callable $isResourceBlocked, string $previewHostname, ?Key $apiKey) { + // add headers here as well $host = $request->getHostname() ?? ''; if (!empty($previewHostname)) { $host = $previewHostname; @@ -370,6 +371,8 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw $headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey; $headers['x-appwrite-trigger'] = 'http'; $headers['x-appwrite-user-jwt'] = ''; + // add here + // in API - for sync executions, hard code delay to 0 $ip = $headers['x-real-ip'] ?? ''; if (!empty($ip)) { diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 86fb1e5822..04e5c41ba5 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -713,7 +713,10 @@ App::shutdown() $queueForEvents->setPayload($responsePayload); } + $scheduledAt = new \DateTime(); + $queueForFunctions + ->setHeaders(['x-appwrite-scheduled-at' => $scheduledAt->format('Y-m-d\TH:i:s.v\Z')]) ->from($queueForEvents) ->trigger(); diff --git a/app/init/constants.php b/app/init/constants.php index ebf79086a7..d58cd60560 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -142,7 +142,7 @@ const APP_AUTH_TYPE_ADMIN = 'Admin'; // Response related const MAX_OUTPUT_CHUNK_SIZE = 10 * 1024 * 1024; // 10MB // Function headers -const FUNCTION_ALLOWLIST_HEADERS_REQUEST = ['content-type', 'agent', 'content-length', 'host']; +const FUNCTION_ALLOWLIST_HEADERS_REQUEST = ['content-type', 'agent', 'content-length', 'host', 'x-appwrite-scheduled-at']; const FUNCTION_ALLOWLIST_HEADERS_RESPONSE = ['content-type', 'content-length']; // Message types const MESSAGE_TYPE_EMAIL = 'email'; diff --git a/src/Appwrite/Event/Func.php b/src/Appwrite/Event/Func.php index ae316c84e5..b6a51e3bc7 100644 --- a/src/Appwrite/Event/Func.php +++ b/src/Appwrite/Event/Func.php @@ -161,7 +161,7 @@ class Func extends Event /** * Sets custom headers for the function event. * - * @param string $headers + * @param array $headers * @return self */ public function setHeaders(array $headers): self diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index d502a78e07..689ab9a67c 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -221,6 +221,8 @@ class Create extends Base $headers['x-appwrite-country-code'] = ''; $headers['x-appwrite-continent-code'] = ''; $headers['x-appwrite-continent-eu'] = 'false'; + $currentTime = new \DateTime(); + $headers['x-appwrite-scheduled-at'] = $currentTime->format('Y-m-d\TH:i:s.v\Z'); $ip = $headers['x-real-ip'] ?? ''; if (!empty($ip)) { diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index 2c9ca16b0c..1335d03aea 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -405,6 +405,11 @@ class Functions extends Action $headers['x-appwrite-country-code'] = ''; $headers['x-appwrite-continent-code'] = ''; $headers['x-appwrite-continent-eu'] = 'false'; + $scheduledAt = $headers['x-appwrite-scheduled-at'] ?? ''; + $executedAt = new \DateTime(); + $headers['x-appwrite-executed-at'] = $executedAt->format('Y-m-d\TH:i:s.v\Z'); + $delay = $executedAt->getTimestamp() - (new \DateTime($scheduledAt))->getTimestamp(); + $headers['x-appwrite-execution-delay'] = (string)floor($delay); /** Create execution or update execution status */ $execution = $dbForProject->getDocument('executions', $executionId ?? ''); @@ -447,8 +452,12 @@ class Functions extends Action } if ($execution->getAttribute('status') !== 'processing') { + $headersFromExecution = $execution->getAttribute('requestHeaders', []); $execution->setAttribute('status', 'processing'); - + $execution->setAttribute('requestHeaders', \array_merge($headersFromExecution, [ + ['name' => 'x-appwrite-executed-at', 'value' => $headers['x-appwrite-executed-at']], + ['name' => 'x-appwrite-execution-delay', 'value' => $headers['x-appwrite-execution-delay'], + ]])); $execution = $dbForProject->updateDocument('executions', $executionId, $execution); } diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index ff99033fdf..8ba4d39a74 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -2282,4 +2282,54 @@ class FunctionsCustomServerTest extends Scope $this->cleanupFunction($functionId); } + + public function testExecutionHeaders() + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test execution headers', + 'execute' => [Role::any()->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + + $deploymentId = $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('basic'), + 'activate' => true, + ]); + + $deployment = $this->getDeployment($functionId, $deploymentId); + $this->assertEquals(200, $deployment['headers']['status-code']); + + $execution = $this->createExecution($functionId, [ + 'async' => true, + ]); + + $this->assertEquals(202, $execution['headers']['status-code']); + $this->assertNotEmpty($execution['body']['$id']); + + $executionId = $execution['body']['$id'] ?? ''; + + sleep(5); + + $execution = $this->getExecution($functionId, $executionId); + $this->assertEquals(200, $execution['headers']['status-code']); + $this->assertEquals('completed', $execution['body']['status']); + $this->assertEquals(200, $execution['body']['responseStatusCode']); + $this->assertGreaterThan(0, $execution['body']['duration']); + $this->assertNotEmpty($execution['body']['logs']); + $this->assertNotEmpty($execution['body']['responseHeaders']); + $requestHeaders = array_column($execution['body']['requestHeaders'], 'value', 'name'); + $this->assertArrayHasKey('x-appwrite-scheduled-at', $requestHeaders); + $this->assertArrayHasKey('x-appwrite-executed-at', $requestHeaders); + $this->assertArrayHasKey('x-appwrite-execution-delay', $requestHeaders); + $this->assertIsNumeric($requestHeaders['x-appwrite-execution-delay']); + $this->assertGreaterThan($requestHeaders['x-appwrite-scheduled-at'], $requestHeaders['x-appwrite-executed-at']); + $this->assertStringContainsString('execution-delay-is-valid', $execution['body']['logs']); + $this->assertStringContainsString('scheduled-at-is-valid', $execution['body']['logs']); + $this->assertStringContainsString('executed-at-is-valid', $execution['body']['logs']); + + $this->cleanupFunction($functionId); + } } diff --git a/tests/resources/functions/basic/index.js b/tests/resources/functions/basic/index.js index 1eb9d38c58..60c7cbb692 100644 --- a/tests/resources/functions/basic/index.js +++ b/tests/resources/functions/basic/index.js @@ -7,6 +7,24 @@ module.exports = async(context) => { } else { context.log('jwt-is-invalid'); } + + if (context.req.headers["x-appwrite-execution-delay"]) { + context.log("execution-delay-is-valid"); + } else { + context.log("execution-delay-is-invalid"); + } + + if (context.req.headers["x-appwrite-scheduled-at"]) { + context.log("scheduled-at-is-valid"); + } else { + context.log("scheduled-at-is-invalid"); + } + + if (context.req.headers["x-appwrite-executed-at"]) { + context.log("executed-at-is-valid"); + } else { + context.log("executed-at-is-invalid"); + } if(context.req.path === '/custom-response') { const code = +(context.req.query['code'] || '200');