diff --git a/app/controllers/general.php b/app/controllers/general.php index 42e26b6fbe..446ffd9058 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -370,6 +370,11 @@ 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 headers for sync executions + $currentTime = new \DateTime(); + $headers['x-appwrite-scheduled-at'] = $currentTime->format('Y-m-d\TH:i:s.v\Z'); + $headers['x-appwrite-executed-at'] = $currentTime->format('Y-m-d\TH:i:s.v\Z'); + $headers['x-appwrite-execution-delay'] = '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..5f88440c2c 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -70,8 +70,11 @@ $eventDatabaseListener = function (Document $project, Document $document, Respon ->setParam('userId', $document->getId()) ->setPayload($response->output($document, Response::MODEL_USER)); + $scheduledAt = new \DateTime(); + // Trigger functions, webhooks, and realtime events $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 62d9f16853..7c0fa2934e 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -144,7 +144,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', 'x-appwrite-execution-delay', 'x-appwrite-executed-at']; const FUNCTION_ALLOWLIST_HEADERS_RESPONSE = ['content-type', 'content-length']; // Message types const MESSAGE_TYPE_EMAIL = 'email'; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 905acf15df..ad1e711ab7 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -228,6 +228,14 @@ 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'); + + if (!$async) { + $executedAt = new \DateTime(); + $headers['x-appwrite-executed-at'] = $executedAt->format('Y-m-d\TH:i:s.v\Z'); + $headers['x-appwrite-execution-delay'] = '0'; + } $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..c5d1c7a976 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -164,7 +164,8 @@ class Functions extends Action method: 'POST', headers: [ 'user-agent' => 'Appwrite/' . APP_VERSION_STABLE, - 'content-type' => 'application/json' + 'content-type' => 'application/json', + 'x-appwrite-scheduled-at' => $headers['x-appwrite-scheduled-at'] ?? '', ], data: null, user: $user, @@ -405,6 +406,13 @@ 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'] ?? ''; + if (!empty($scheduledAt)) { + $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(max(0, $delay)); + } /** Create execution or update execution status */ $execution = $dbForProject->getDocument('executions', $executionId ?? ''); @@ -447,8 +455,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..365be656c7 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -1508,6 +1508,17 @@ class FunctionsCustomServerTest extends Scope $this->assertEquals(204, $lastExecution['responseStatusCode']); $this->assertStringContainsString($userId, $lastExecution['logs']); $this->assertStringContainsString('Event User', $lastExecution['logs']); + + $requestHeaders = array_column($lastExecution['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', $lastExecution['logs']); + $this->assertStringContainsString('scheduled-at-is-valid', $lastExecution['logs']); + $this->assertStringContainsString('executed-at-is-valid', $lastExecution['logs']); + }, 10000, 500); $this->cleanupFunction($functionId); @@ -2282,4 +2293,94 @@ class FunctionsCustomServerTest extends Scope $this->cleanupFunction($functionId); } + + public function testAsyncExecutionHeaders() + { + $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); + } + + public function testSyncExecutionHeaders() + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test sync 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' => false, + ]); + + $this->assertEquals(201, $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->assertEquals('0', $requestHeaders['x-appwrite-execution-delay']); + $this->assertGreaterThanOrEqual($requestHeaders['x-appwrite-scheduled-at'], $requestHeaders['x-appwrite-executed-at']); + + $this->cleanupFunction($functionId); + } } diff --git a/tests/e2e/Services/FunctionsSchedule/FunctionsScheduleTest.php b/tests/e2e/Services/FunctionsSchedule/FunctionsScheduleTest.php index 3c24b7b87d..dc706498f3 100644 --- a/tests/e2e/Services/FunctionsSchedule/FunctionsScheduleTest.php +++ b/tests/e2e/Services/FunctionsSchedule/FunctionsScheduleTest.php @@ -118,7 +118,7 @@ class FunctionsScheduleTest extends Scope $this->assertEquals('scheduled', $execution['body']['status']); $this->assertEquals('PATCH', $execution['body']['requestMethod']); $this->assertEquals('/custom-path', $execution['body']['requestPath']); - $this->assertCount(0, $execution['body']['requestHeaders']); + $this->assertCount(1, $execution['body']['requestHeaders']); \sleep(120); diff --git a/tests/resources/functions/basic/index.js b/tests/resources/functions/basic/index.js index c46eeb42c1..2527b56075 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'); diff --git a/tests/resources/functions/event-handler/index.js b/tests/resources/functions/event-handler/index.js index 6df3b7fb35..3ffcc7a1d5 100644 --- a/tests/resources/functions/event-handler/index.js +++ b/tests/resources/functions/event-handler/index.js @@ -1,5 +1,22 @@ module.exports = async(context) => { context.log(context.req.body.$id); context.log(context.req.body.name); + 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"); + } return context.res.empty(); };