summaryrefslogtreecommitdiff
path: root/cli
diff options
context:
space:
mode:
authorIgor Zinkovsky <igor@deno.com>2023-11-01 11:57:55 -0700
committerGitHub <noreply@github.com>2023-11-01 11:57:55 -0700
commit01d3e0f317ca180bbf0ac8a17c6651869110e02f (patch)
treeb8e3bb91e87cf396bfa782f6377158f8a170b32b /cli
parent82643857cc77a80f9a819584035ec147a6114553 (diff)
feat(cron) implement Deno.cron() (#21019)
This PR adds unstable `Deno.cron` API to trigger execution of cron jobs. * State: All cron state is in memory. Cron jobs are scheduled according to the cron schedule expression and the current time. No state is persisted to disk. * Time zone: Cron expressions specify time in UTC. * Overlapping executions: not permitted. If the next scheduled execution time occurs while the same cron job is still executing, the scheduled execution is skipped. * Retries: failed jobs are automatically retried until they succeed or until retry threshold is reached. Retry policy can be optionally specified using `options.backoffSchedule`.
Diffstat (limited to 'cli')
-rw-r--r--cli/build.rs2
-rw-r--r--cli/tests/integration/js_unit_tests.rs1
-rw-r--r--cli/tests/unit/cron_test.ts242
-rw-r--r--cli/tsc/dts/lib.deno.unstable.d.ts25
4 files changed, 270 insertions, 0 deletions
diff --git a/cli/build.rs b/cli/build.rs
index e6b9dc0a4..b7a452465 100644
--- a/cli/build.rs
+++ b/cli/build.rs
@@ -349,6 +349,7 @@ deno_core::extension!(
fn create_cli_snapshot(snapshot_path: PathBuf) -> CreateSnapshotOutput {
use deno_core::Extension;
use deno_runtime::deno_cache::SqliteBackedCache;
+ use deno_runtime::deno_cron::local::LocalCronHandler;
use deno_runtime::deno_http::DefaultHttpPropertyExtractor;
use deno_runtime::deno_kv::sqlite::SqliteDbHandler;
use deno_runtime::permissions::PermissionsContainer;
@@ -383,6 +384,7 @@ fn create_cli_snapshot(snapshot_path: PathBuf) -> CreateSnapshotOutput {
deno_kv::deno_kv::init_ops(SqliteDbHandler::<PermissionsContainer>::new(
None, None,
)),
+ deno_cron::deno_cron::init_ops(LocalCronHandler::new()),
deno_napi::deno_napi::init_ops::<PermissionsContainer>(),
deno_http::deno_http::init_ops::<DefaultHttpPropertyExtractor>(),
deno_io::deno_io::init_ops(Default::default()),
diff --git a/cli/tests/integration/js_unit_tests.rs b/cli/tests/integration/js_unit_tests.rs
index f110f8aa6..863776aa2 100644
--- a/cli/tests/integration/js_unit_tests.rs
+++ b/cli/tests/integration/js_unit_tests.rs
@@ -24,6 +24,7 @@ util::unit_test_factory!(
console_test,
copy_file_test,
custom_event_test,
+ cron_test,
dir_test,
dom_exception_test,
error_stack_test,
diff --git a/cli/tests/unit/cron_test.ts b/cli/tests/unit/cron_test.ts
new file mode 100644
index 000000000..636a04fd2
--- /dev/null
+++ b/cli/tests/unit/cron_test.ts
@@ -0,0 +1,242 @@
+// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
+import { assertEquals, assertThrows, deferred } from "./test_util.ts";
+
+const sleep = (time: number) => new Promise((r) => setTimeout(r, time));
+
+Deno.test(function noNameTest() {
+ assertThrows(
+ // @ts-ignore test
+ () => Deno.cron(),
+ TypeError,
+ "Deno.cron requires a unique name",
+ );
+});
+
+Deno.test(function noSchedule() {
+ assertThrows(
+ // @ts-ignore test
+ () => Deno.cron("foo"),
+ TypeError,
+ "Deno.cron requires a valid schedule",
+ );
+});
+
+Deno.test(function noHandler() {
+ assertThrows(
+ // @ts-ignore test
+ () => Deno.cron("foo", "*/1 * * * *"),
+ TypeError,
+ "Deno.cron requires a handler",
+ );
+});
+
+Deno.test(function invalidNameTest() {
+ assertThrows(
+ () => Deno.cron("abc[]", "*/1 * * * *", () => {}),
+ TypeError,
+ "Invalid cron name",
+ );
+ assertThrows(
+ () => Deno.cron("a**bc", "*/1 * * * *", () => {}),
+ TypeError,
+ "Invalid cron name",
+ );
+ assertThrows(
+ () => Deno.cron("abc<>", "*/1 * * * *", () => {}),
+ TypeError,
+ "Invalid cron name",
+ );
+ assertThrows(
+ () => Deno.cron(";']", "*/1 * * * *", () => {}),
+ TypeError,
+ "Invalid cron name",
+ );
+ assertThrows(
+ () =>
+ Deno.cron(
+ "0000000000000000000000000000000000000000000000000000000000000000000000",
+ "*/1 * * * *",
+ () => {},
+ ),
+ TypeError,
+ "Cron name is too long",
+ );
+});
+
+Deno.test(function invalidScheduleTest() {
+ assertThrows(
+ () => Deno.cron("abc", "bogus", () => {}),
+ TypeError,
+ "Invalid cron schedule",
+ );
+ assertThrows(
+ () => Deno.cron("abc", "* * * * * *", () => {}),
+ TypeError,
+ "Invalid cron schedule",
+ );
+ assertThrows(
+ () => Deno.cron("abc", "* * * *", () => {}),
+ TypeError,
+ "Invalid cron schedule",
+ );
+ assertThrows(
+ () => Deno.cron("abc", "m * * * *", () => {}),
+ TypeError,
+ "Invalid cron schedule",
+ );
+});
+
+Deno.test(function invalidBackoffScheduleTest() {
+ assertThrows(
+ () =>
+ Deno.cron("abc", "*/1 * * * *", () => {}, {
+ backoffSchedule: [1, 1, 1, 1, 1, 1],
+ }),
+ TypeError,
+ "Invalid backoff schedule",
+ );
+ assertThrows(
+ () =>
+ Deno.cron("abc", "*/1 * * * *", () => {}, {
+ backoffSchedule: [3600001],
+ }),
+ TypeError,
+ "Invalid backoff schedule",
+ );
+});
+
+Deno.test(async function tooManyCrons() {
+ const crons: Promise<void>[] = [];
+ const ac = new AbortController();
+ for (let i = 0; i <= 100; i++) {
+ const c = Deno.cron(`abc_${i}`, "*/1 * * * *", () => {}, {
+ signal: ac.signal,
+ });
+ crons.push(c);
+ }
+
+ try {
+ assertThrows(
+ () => {
+ Deno.cron("next-cron", "*/1 * * * *", () => {}, { signal: ac.signal });
+ },
+ TypeError,
+ "Too many crons",
+ );
+ } finally {
+ ac.abort();
+ for (const c of crons) {
+ await c;
+ }
+ }
+});
+
+Deno.test(async function duplicateCrons() {
+ const ac = new AbortController();
+ const c = Deno.cron("abc", "*/20 * * * *", () => {
+ }, { signal: ac.signal });
+ try {
+ assertThrows(
+ () => Deno.cron("abc", "*/20 * * * *", () => {}),
+ TypeError,
+ "Cron with this name already exists",
+ );
+ } finally {
+ ac.abort();
+ await c;
+ }
+});
+
+Deno.test(async function basicTest() {
+ Deno.env.set("DENO_CRON_TEST_SCHEDULE_OFFSET", "100");
+
+ let count = 0;
+ const promise = deferred();
+ const ac = new AbortController();
+ const c = Deno.cron("abc", "*/20 * * * *", () => {
+ count++;
+ if (count > 5) {
+ promise.resolve();
+ }
+ }, { signal: ac.signal });
+ try {
+ await promise;
+ } finally {
+ ac.abort();
+ await c;
+ }
+});
+
+Deno.test(async function multipleCrons() {
+ Deno.env.set("DENO_CRON_TEST_SCHEDULE_OFFSET", "100");
+
+ let count0 = 0;
+ let count1 = 0;
+ const promise0 = deferred();
+ const promise1 = deferred();
+ const ac = new AbortController();
+ const c0 = Deno.cron("abc", "*/20 * * * *", () => {
+ count0++;
+ if (count0 > 5) {
+ promise0.resolve();
+ }
+ }, { signal: ac.signal });
+ const c1 = Deno.cron("xyz", "*/20 * * * *", () => {
+ count1++;
+ if (count1 > 5) {
+ promise1.resolve();
+ }
+ }, { signal: ac.signal });
+ try {
+ await promise0;
+ await promise1;
+ } finally {
+ ac.abort();
+ await c0;
+ await c1;
+ }
+});
+
+Deno.test(async function overlappingExecutions() {
+ Deno.env.set("DENO_CRON_TEST_SCHEDULE_OFFSET", "100");
+
+ let count = 0;
+ const promise0 = deferred();
+ const promise1 = deferred();
+ const ac = new AbortController();
+ const c = Deno.cron("abc", "*/20 * * * *", async () => {
+ promise0.resolve();
+ count++;
+ await promise1;
+ }, { signal: ac.signal });
+ try {
+ await promise0;
+ } finally {
+ await sleep(2000);
+ promise1.resolve();
+ ac.abort();
+ await c;
+ }
+ assertEquals(count, 1);
+});
+
+Deno.test(async function retriesWithBackkoffSchedule() {
+ Deno.env.set("DENO_CRON_TEST_SCHEDULE_OFFSET", "5000");
+
+ let count = 0;
+ const ac = new AbortController();
+ const c = Deno.cron("abc", "*/20 * * * *", async () => {
+ count += 1;
+ await sleep(10);
+ throw new TypeError("cron error");
+ }, { signal: ac.signal, backoffSchedule: [10, 20] });
+ try {
+ await sleep(6000);
+ } finally {
+ ac.abort();
+ await c;
+ }
+
+ // The cron should have executed 3 times (1st attempt and 2 retries).
+ assertEquals(count, 3);
+});
diff --git a/cli/tsc/dts/lib.deno.unstable.d.ts b/cli/tsc/dts/lib.deno.unstable.d.ts
index 56188f3b8..c758e620c 100644
--- a/cli/tsc/dts/lib.deno.unstable.d.ts
+++ b/cli/tsc/dts/lib.deno.unstable.d.ts
@@ -1319,6 +1319,31 @@ declare namespace Deno {
/** **UNSTABLE**: New API, yet to be vetted.
*
+ * Create a cron job that will periodically execute the provided handler
+ * callback based on the specified schedule.
+ *
+ * ```ts
+ * Deno.cron("sample cron", "*\/20 * * * *", () => {
+ * console.log("cron job executed");
+ * });
+ * ```
+ * `backoffSchedule` option can be used to specify the retry policy for failed
+ * executions. Each element in the array represents the number of milliseconds
+ * to wait before retrying the execution. For example, `[1000, 5000, 10000]`
+ * means that a failed execution will be retried at most 3 times, with 1
+ * second, 5 seconds, and 10 seconds delay between each retry.
+ *
+ * @category Cron
+ */
+ export function cron(
+ name: string,
+ schedule: string,
+ handler: () => Promise<void> | void,
+ options?: { backoffSchedule?: number[]; signal?: AbortSignal },
+ ): Promise<void>;
+
+ /** **UNSTABLE**: New API, yet to be vetted.
+ *
* A key to be persisted in a {@linkcode Deno.Kv}. A key is a sequence
* of {@linkcode Deno.KvKeyPart}s.
*