Skip to content

Latest commit

 

History

History
516 lines (410 loc) · 14.2 KB

File metadata and controls

516 lines (410 loc) · 14.2 KB

import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem";

Scheduler

The scheduler allows your app to perform actions at specific times, such as sending private messages, tracking upvotes, or scheduling timeouts for user actions. You can schedule both recurring and one-off jobs using the scheduler.


Scheduling recurring jobs

To create a regularly occurring event in your app, declare a task in your devvit.json and handle the event in your server logic.

1. Add a recurring task to devvit.json

Ensure the endpoint follows the format /internal/.+ and specify a cron schedule:

"scheduler": {
  "tasks": {
    "regular-interval-example-task": {
      "endpoint": "/internal/scheduler/regular-interval-task-example",
      "cron": "*/1 * * * *"
    }
  }
},
  • The cron parameter uses the standard UNIX cron format:
    # * * * * *
    # | | | | |
    # | | | | day of the week (0–6, Sunday to Saturday; 7 is also Sunday on some systems)
    # | | | month (1–12)
    # | | day of the month (1–31)
    # | hour (0–23)
    # minute (0–59)
    
  • We recommend using Cronitor to build cron strings.

2. Handle the event in your server

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

import type { TaskRequest, TaskResponse } from "@devvit/web/server";

app.post("/internal/scheduler/regular-interval-task-example", async (c) => {
  const _input = await c.req.json<TaskRequest>();
  console.log(`Handle event for cron example at ${new Date().toISOString()}!`);
  // Handle the event here
  return c.json<TaskResponse>({ status: "ok" }, 200);
});
import type { TaskRequest, TaskResponse } from "@devvit/web/server";

app.post<string, never, TaskResponse, TaskRequest>(
  "/internal/scheduler/regular-interval-task-example",
  async (_req, res) => {
    console.log(
      `Handle event for cron example at ${new Date().toISOString()}!`,
    );
    // Handle the event here
    res.status(200).json({ status: "ok" });
  },
);

Scheduling one-off jobs at runtime

One-off tasks must also be declared in devvit.json.

1. Add the tasks to devvit.json

"scheduler": {
  "tasks": {
    "regular-interval-task-example": {
      "endpoint": "/internal/scheduler/regular-interval-task-example",
      "cron": "*/1 * * * *"
    },
    "one-off-task-example": {
      "endpoint": "/internal/scheduler/one-off-task-example"
    }
  }
}

2. Schedule a job at runtime

Example usage:

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

import type { TaskRequest, TaskResponse } from "@devvit/web/server";

app.post("/internal/scheduler/one-off-task-example", async (c) => {
  const { data } = await c.req.json<TaskRequest<{ postId: string }>>();
  const { postId } = data!;
  const oneMinuteFromNow = new Date(Date.now() + 1000 * 60);

  const scheduledJob: ScheduledJob = {
    id: `job-one-off-for-post${postId}`,
    name: "one-off-task-example",
    data: { postId },
    runAt: oneMinuteFromNow,
  };

  const jobId = await scheduler.runJob(scheduledJob);
  console.log(`Scheduled job ${jobId} for post ${postId}`);
  console.log(`Handle event for one-off event at ${new Date().toISOString()}!`);
  // Handle the event here
  return c.json<TaskResponse>({ status: "ok" }, 200);
});
import type { TaskRequest, TaskResponse } from "@devvit/web/server";

app.post<string, never, TaskResponse, TaskRequest<{ postId: string }>>(
  "/internal/scheduler/one-off-task-example",
  async (req, res) => {
    const { data } = req.body;
    const { postId } = data!;
  const oneMinuteFromNow = new Date(Date.now() + 1000 * 60);

  const scheduledJob: ScheduledJob = {
    id: `job-one-off-for-post${postId}`,
    name: "one-off-task-example",
    data: { postId },
    runAt: oneMinuteFromNow,
  };

  const jobId = await scheduler.runJob(scheduledJob);
  console.log(`Scheduled job ${jobId} for post ${postId}`);
  console.log(`Handle event for one-off event at ${new Date().toISOString()}!`);
  // Handle the event here
  res.status(200).json({ status: "ok" });
  },
);

Cancel a scheduled job

Use the job ID to cancel a scheduled action and remove it from your app. This example shows how to set up a moderator menu action to cancel a job.

1. Add menu item to devvit.json

{
  "menu": {
    "items": [
      {
        "label": "Cancel Job",
        "description": "Cancel a scheduled job",
        "forUserType": "moderator",
        "location": "post",
        "endpoint": "/internal/menu/cancel-job"
      }
    ]
  },
  "permissions": {
    "redis": true
  }
}

2. Handle the menu action in your server

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

import type { MenuItemRequest, UiResponse } from "@devvit/web/shared";

app.post("/internal/menu/cancel-job", async (c) => {
  try {
    // Get the post ID from the menu action request
    const { targetId: postId } = await c.req.json<MenuItemRequest>();

    // Retrieve the job ID from Redis (stored when the job was created)
    const jobId = await redis.get(`job:${postId}`);

    if (!jobId) {
      return c.json<UiResponse>({
        showToast: {
          text: "No scheduled job found for this post",
          appearance: "neutral",
        },
      });
    }

    // Cancel the scheduled job
    await scheduler.cancelJob(jobId);

    // Clean up the stored job ID
    await redis.del(`job:${postId}`);

    return c.json<UiResponse>({
      showToast: {
        text: "Successfully cancelled the scheduled job",
        appearance: "success",
      },
    });
  } catch (error) {
    console.error("Error cancelling job:", error);
    return c.json<UiResponse>({
      showToast: {
        text: "Failed to cancel job",
        appearance: "neutral",
      },
    });
  }
});
import type { MenuItemRequest, UiResponse } from "@devvit/web/shared";

app.post<string, never, UiResponse, MenuItemRequest>(
  "/internal/menu/cancel-job",
  async (req, res) => {
    try {
      // Get the post ID from the menu action request
      const postId = req.body.targetId;

    // Retrieve the job ID from Redis (stored when the job was created)
    const jobId = await redis.get(`job:${postId}`);

      if (!jobId) {
        return res.json({
          showToast: {
            text: "No scheduled job found for this post",
            appearance: "neutral",
          },
        });
      }

    // Cancel the scheduled job
    await scheduler.cancelJob(jobId);

    // Clean up the stored job ID
    await redis.del(`job:${postId}`);

      return res.json({
        showToast: {
          text: "Successfully cancelled the scheduled job",
          appearance: "success",
        },
      });
    } catch (error) {
      console.error("Error cancelling job:", error);
      return res.json({
        showToast: {
          text: "Failed to cancel job",
          appearance: "neutral",
        },
      });
    }
  },
);

Example: Storing a job ID when creating a job

When you create a scheduled job, store its ID in Redis so you can reference it later

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

type ScheduleActionRequest = { postId: string; delayMinutes: number };
type ScheduleActionResponse = { jobId: string; message: string };

app.post("/api/schedule-action", async (c) => {
  const { postId, delayMinutes } =
    await c.req.json<ScheduleActionRequest>();
  const runAt = new Date(Date.now() + delayMinutes * 60 * 1000);

  const scheduledJob: ScheduledJob = {
    id: `job-${postId}-${Date.now()}`,
    name: "one-off-task-example",
    data: { postId },
    runAt,
  };

  const jobId = await scheduler.runJob(scheduledJob);

  // Store the job ID in Redis for later cancellation
  await redis.set(`job:${postId}`, jobId);

  return c.json<ScheduleActionResponse>({
    jobId,
    message: "Job scheduled successfully",
  });
});
type ScheduleActionRequest = { postId: string; delayMinutes: number };
type ScheduleActionResponse = { jobId: string; message: string };

app.post<string, never, ScheduleActionResponse, ScheduleActionRequest>(
  "/api/schedule-action",
  async (req, res) => {
    const { postId, delayMinutes } = req.body;
  const runAt = new Date(Date.now() + delayMinutes * 60 * 1000);

  const scheduledJob: ScheduledJob = {
    id: `job-${postId}-${Date.now()}`,
    name: "one-off-task-example",
    data: { postId },
    runAt,
  };

  const jobId = await scheduler.runJob(scheduledJob);

  // Store the job ID in Redis for later cancellation
  await redis.set(`job:${postId}`, jobId);

    return res.json({
      jobId,
      message: "Job scheduled successfully",
    });
  },
);

List jobs

This example shows how to handle a request within your server/index.ts to list your scheduled jobs and return them to the client.

<Tabs variant="pill" groupId="http-server-framework" defaultValue="hono" values={[ { label: 'Hono', value: 'hono' }, { label: 'Express', value: 'express' }, ]}>

type ListJobsSuccessResponse = {
  status: "success";
  jobs: (ScheduledJob | ScheduledCronJob)[];
  count: number;
};
type ListJobsErrorResponse = { status: "error"; message: string };
type ListJobsResponse = ListJobsSuccessResponse | ListJobsErrorResponse;

app.get("/api/list-jobs", async (c) => {
  try {
    const jobs: (ScheduledJob | ScheduledCronJob)[] =
      await scheduler.listJobs();

    console.log(`[LIST] Found ${jobs.length} scheduled jobs`);

    return c.json<ListJobsResponse>({
      status: "success",
      jobs,
      count: jobs.length,
    });
  } catch (error) {
    console.error(`[LIST] Error listing jobs:`, error);
    return c.json<ListJobsResponse>(
      {
        status: "error",
        message: error instanceof Error ? error.message : "Failed to list jobs",
      },
      500,
    );
  }
});
type ListJobsSuccessResponse = {
  status: "success";
  jobs: (ScheduledJob | ScheduledCronJob)[];
  count: number;
};
type ListJobsErrorResponse = { status: "error"; message: string };
type ListJobsResponse = ListJobsSuccessResponse | ListJobsErrorResponse;

app.get<string, never, ListJobsResponse, never>(
  "/api/list-jobs",
  async (_req, res): Promise<void> => {
    try {
      const jobs: (ScheduledJob | ScheduledCronJob)[] =
        await scheduler.listJobs();

    console.log(`[LIST] Found ${jobs.length} scheduled jobs`);

      res.json({
        status: "success",
        jobs,
        count: jobs.length,
      });
    } catch (error) {
      console.error(`[LIST] Error listing jobs:`, error);
      res.status(500).json({
        status: "error",
        message: error instanceof Error ? error.message : "Failed to list jobs",
      });
    }
  },
);

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.

import { redis, type TaskRequest, type TaskResponse } from "@devvit/web/server";

app.post("/internal/scheduler/send-notification", async (c) => {
  const { data } = await c.req.json<TaskRequest<{ postId: string }>>();
  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<TaskResponse>({ status: "ok" }, 200);
  }

  await sendNotification(postId);
  return c.json<TaskResponse>({ status: "ok" }, 200);
});

Faster scheduler

:::note This feature is experimental, which means the design is not final but it's still available for you to use. :::

Scheduled jobs currently perform one scheduled run per minute. To go faster, you can now run jobs every second by adding seconds granularity to your cron expression.

await scheduler.runJob({
  name: "run_every_30_seconds",
  cron: "*/30 * * * * *",
});

How frequent a scheduled job runs will depend on how long the job takes to complete and how many jobs are running in parallel. This means a job may take a bit longer than scheduled, but the overall resolution should be better than a minute.


Limitations

Limits are per installation of an app:

  1. An installation can have up to 10 live recurring actions.
  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.