From 51cddd313119bc9bdfd09071096996b3625a24ec Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Tue, 21 Jul 2026 11:31:38 -0400 Subject: [PATCH] docs: explain scheduler execution semantics --- docs/capabilities/server/scheduler.mdx | 34 +++++++++++++++++++ .../capabilities/server/scheduler.mdx | 34 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/docs/capabilities/server/scheduler.mdx b/docs/capabilities/server/scheduler.mdx index b7b870f2..a22a47fb 100644 --- a/docs/capabilities/server/scheduler.mdx +++ b/docs/capabilities/server/scheduler.mdx @@ -453,6 +453,39 @@ app.get( +## Retries and duplicate execution + +:::warning +The scheduler may invoke the same job more than once. Make scheduled handlers idempotent. +::: + +When the scheduler receives a successful response or an application error, it marks the job complete. If an infrastructure failure or the 30-second execution timeout prevents the scheduler from receiving a reliable response, it leaves the job incomplete and retries it for up to four hours. + +This can create duplicate execution even when your handler worked. For example, your handler might send a notification, but an infrastructure failure might prevent the scheduler from recording that the job completed. The scheduler then runs the job again. + +Use a stable identifier from the job data to deduplicate side effects. This example sends one notification per post, so it uses the post ID. Store the identifier atomically in Redis before performing the side effect, and always set an expiration of at least four hours on the key. + +```ts title="/server/index.ts" +import { redis, type TaskRequest, type TaskResponse } from "@devvit/web/server"; + +app.post("/internal/scheduler/send-notification", async (c) => { + const { data } = await c.req.json>(); + const { postId } = data!; + + const claimed = await redis.set(`scheduled:notifications:${postId}`, "1", { + nx: true, + expiration: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + if (claimed !== "OK") { + return c.json({ status: "ok" }, 200); + } + + await sendNotification(postId); + return c.json({ status: "ok" }, 200); +}); +``` + ## Faster scheduler :::note @@ -480,3 +513,4 @@ _Limits are per installation of an app:_ 2. The `runJob()` method enforces two rate limits when creating actions: - **Creation rate:** Up to 60 calls to `runJob()` per minute - **Delivery rate:** Up to 60 deliveries per minute +3. A scheduled job has a **30-second execution timeout**. diff --git a/versioned_docs/version-0.13/capabilities/server/scheduler.mdx b/versioned_docs/version-0.13/capabilities/server/scheduler.mdx index b7b870f2..a22a47fb 100644 --- a/versioned_docs/version-0.13/capabilities/server/scheduler.mdx +++ b/versioned_docs/version-0.13/capabilities/server/scheduler.mdx @@ -453,6 +453,39 @@ app.get( +## Retries and duplicate execution + +:::warning +The scheduler may invoke the same job more than once. Make scheduled handlers idempotent. +::: + +When the scheduler receives a successful response or an application error, it marks the job complete. If an infrastructure failure or the 30-second execution timeout prevents the scheduler from receiving a reliable response, it leaves the job incomplete and retries it for up to four hours. + +This can create duplicate execution even when your handler worked. For example, your handler might send a notification, but an infrastructure failure might prevent the scheduler from recording that the job completed. The scheduler then runs the job again. + +Use a stable identifier from the job data to deduplicate side effects. This example sends one notification per post, so it uses the post ID. Store the identifier atomically in Redis before performing the side effect, and always set an expiration of at least four hours on the key. + +```ts title="/server/index.ts" +import { redis, type TaskRequest, type TaskResponse } from "@devvit/web/server"; + +app.post("/internal/scheduler/send-notification", async (c) => { + const { data } = await c.req.json>(); + const { postId } = data!; + + const claimed = await redis.set(`scheduled:notifications:${postId}`, "1", { + nx: true, + expiration: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + if (claimed !== "OK") { + return c.json({ status: "ok" }, 200); + } + + await sendNotification(postId); + return c.json({ status: "ok" }, 200); +}); +``` + ## Faster scheduler :::note @@ -480,3 +513,4 @@ _Limits are per installation of an app:_ 2. The `runJob()` method enforces two rate limits when creating actions: - **Creation rate:** Up to 60 calls to `runJob()` per minute - **Delivery rate:** Up to 60 deliveries per minute +3. A scheduled job has a **30-second execution timeout**.