diff options
author | Igor Zinkovsky <igor@deno.com> | 2023-11-01 11:57:55 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-01 11:57:55 -0700 |
commit | 01d3e0f317ca180bbf0ac8a17c6651869110e02f (patch) | |
tree | b8e3bb91e87cf396bfa782f6377158f8a170b32b /cli | |
parent | 82643857cc77a80f9a819584035ec147a6114553 (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.rs | 2 | ||||
-rw-r--r-- | cli/tests/integration/js_unit_tests.rs | 1 | ||||
-rw-r--r-- | cli/tests/unit/cron_test.ts | 242 | ||||
-rw-r--r-- | cli/tsc/dts/lib.deno.unstable.d.ts | 25 |
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. * |