├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── redis.conf └── src ├── job_scheduler ├── LICENSE └── mod.rs └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "redis_cron" 3 | version = "0.1.0" 4 | authors = ["Prashanth Pai "] 5 | edition = "2018" 6 | license = "MIT" 7 | 8 | [lib] 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | lazy_static = "1.4.0" 13 | cron = "~0.8" 14 | chrono = "~0.4" 15 | uuid = { version = "~0.8", features = ["v4"] } 16 | redis-module = { git="https://github.com/RedisLabsModules/redismodule-rs", features = ["experimental-api"]} 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Prashanth Pai and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redis_cron 2 | 3 | redis_cron is a simple cron expression based job scheduler for Redis that runs 4 | inside redis as a module. It uses similar syntax as a regular cron and allows 5 | you to schedule redis commands directly on redis. This project is inspired from 6 | PostgreSQL extension [pg_cron](https://github.com/citusdata/pg_cron). 7 | 8 | redis_cron runs scheduled jobs sequentially in a single thread since redis 9 | commands can only be run by one thread at a time inside redis. 10 | 11 | ## Install 12 | 13 | ```sh 14 | $ cargo build 15 | $ # Mac: 16 | $ redis-server --loadmodule ./target/debug/libredis_cron.dylib 17 | $ # Linux: 18 | $ redis-server --loadmodule ./target/debug/libredis_cron.so 19 | ``` 20 | 21 | ## Usage 22 | 23 | Available commands: 24 | 25 | ``` 26 | CRON.SCHEDULE 27 | CRON.UNSCHEDULE 28 | CRON.LIST 29 | ``` 30 | 31 | **Example** 32 | 33 | ``` 34 | $ redis-cli 35 | 127.0.0.1:6379> CRON.SCHEDULE "1/10 * * * * *" EVAL "return redis.call('set','foo','bar')" 0 36 | "f04fefb1-ebf1-4d47-a582-f04963df994b" 37 | 127.0.0.1:6379> CRON.SCHEDULE "1/5 * * * * *" HINCRBY myhash field 1 38 | "be428e43-5501-4eed-83c3-2c9ef3c52f6f" 39 | 127.0.0.1:6379> CRON.LIST 40 | 1) 1) f04fefb1-ebf1-4d47-a582-f04963df994b 41 | 2) 1/10 * * * * * 42 | 3) EVAL return redis.call('set','foo','bar') 0 43 | 2) 1) be428e43-5501-4eed-83c3-2c9ef3c52f6f 44 | 2) 1/5 * * * * * 45 | 3) HINCRBY myhash field 1 46 | 127.0.0.1:6379> CRON.UNSCHEDULE be428e43-5501-4eed-83c3-2c9ef3c52f6f 47 | (integer) 1 48 | 127.0.0.1:6379> CRON.LIST 49 | 1) 1) f04fefb1-ebf1-4d47-a582-f04963df994b 50 | 2) 1/10 * * * * * 51 | 3) EVAL return redis.call('set','foo','bar') 0 52 | 127.0.0.1:6379> CRON.UNSCHEDULE f04fefb1-ebf1-4d47-a582-f04963df994b 53 | (integer) 1 54 | 127.0.0.1:6379> CRON.LIST 55 | (empty array) 56 | ``` 57 | 58 | Logs: 59 | 60 | ```log 61 | 66004:M 06 Mar 2021 15:33:22.814 # Server initialized 62 | 66004:M 06 Mar 2021 15:33:22.815 * Module 'cron' loaded from ./target/debug/libredis_cron.dylib 63 | 66004:M 06 Mar 2021 15:33:22.815 * Ready to accept connections 64 | 66004:M 06 Mar 2021 15:34:01.462 * redis_cron: run: job_id=f04fefb1-ebf1-4d47-a582-f04963df994b; schedule=1/10 * * * * *; cmd=EVAL; 65 | 66004:M 06 Mar 2021 15:34:06.486 * redis_cron: run: job_id=be428e43-5501-4eed-83c3-2c9ef3c52f6f; schedule=1/5 * * * * *; cmd=HINCRBY; 66 | 66004:M 06 Mar 2021 15:34:11.000 * redis_cron: run: job_id=f04fefb1-ebf1-4d47-a582-f04963df994b; schedule=1/10 * * * * *; cmd=EVAL; 67 | 66004:M 06 Mar 2021 15:34:11.000 * redis_cron: run: job_id=be428e43-5501-4eed-83c3-2c9ef3c52f6f; schedule=1/5 * * * * *; cmd=HINCRBY; 68 | 66004:M 06 Mar 2021 15:34:16.017 * redis_cron: run: job_id=be428e43-5501-4eed-83c3-2c9ef3c52f6f; schedule=1/5 * * * * *; cmd=HINCRBY; 69 | 66004:M 06 Mar 2021 15:34:21.043 * redis_cron: run: job_id=f04fefb1-ebf1-4d47-a582-f04963df994b; schedule=1/10 * * * * *; cmd=EVAL; 70 | 66004:M 06 Mar 2021 15:34:21.043 * redis_cron: run: job_id=be428e43-5501-4eed-83c3-2c9ef3c52f6f; schedule=1/5 * * * * *; cmd=HINCRBY; 71 | 66004:M 06 Mar 2021 15:34:26.069 * redis_cron: run: job_id=be428e43-5501-4eed-83c3-2c9ef3c52f6f; schedule=1/5 * * * * *; cmd=HINCRBY; 72 | 66004:M 06 Mar 2021 15:34:31.086 * redis_cron: run: job_id=f04fefb1-ebf1-4d47-a582-f04963df994b; schedule=1/10 * * * * *; cmd=EVAL; 73 | 66004:M 06 Mar 2021 15:34:41.116 * redis_cron: run: job_id=f04fefb1-ebf1-4d47-a582-f04963df994b; schedule=1/10 * * * * *; cmd=EVAL; 74 | ``` 75 | 76 | ## Cron expression syntax 77 | 78 | Creating a schedule for a job is done using the `FromStr` impl for the 79 | `Schedule` type of the [cron](https://github.com/zslayton/cron) library. 80 | 81 | The scheduling format is as follows: 82 | 83 | ```text 84 | sec min hour day of month month day of week year 85 | * * * * * * * 86 | ``` 87 | 88 | Time is specified for `UTC` and not your local timezone. Note that the year may 89 | be omitted. 90 | 91 | Comma separated values such as `5,8,10` represent more than one time value. So 92 | for example, a schedule of `0 2,14,26 * * * *` would execute on the 2nd, 14th, 93 | and 26th minute of every hour. 94 | 95 | Ranges can be specified with a dash. A schedule of `0 0 * 5-10 * *` would 96 | execute once per hour but only on day 5 through 10 of the month. 97 | 98 | Day of the week can be specified as an abbreviation or the full name. A 99 | schedule of `0 0 6 * * Sun,Sat` would execute at 6am on Sunday and Saturday. 100 | 101 | ### Reference 102 | 103 | This project uses makes use of [redismodule-rs](https://github.com/RedisLabsModules/redismodule-rs) 104 | -------------------------------------------------------------------------------- /redis.conf: -------------------------------------------------------------------------------- 1 | loadmodule target/debug/libredis_cron.dylib 2 | -------------------------------------------------------------------------------- /src/job_scheduler/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Lori Holden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/job_scheduler/mod.rs: -------------------------------------------------------------------------------- 1 | //! This is forked from https://github.com/lholden/job_scheduler 2 | 3 | extern crate redis_module; 4 | use redis_module::ThreadSafeContext; 5 | 6 | use chrono::{offset, DateTime, Duration, Utc}; 7 | pub use cron::Schedule; 8 | pub use uuid::Uuid; 9 | 10 | pub struct Job { 11 | job_id: Uuid, 12 | schedule: Schedule, 13 | cmd_args: Vec, 14 | limit_missed_runs: usize, 15 | last_tick: Option>, 16 | } 17 | 18 | pub struct JobStr { 19 | pub job_id: String, 20 | pub schedule: String, 21 | pub cmd_args: String, 22 | } 23 | 24 | impl Job { 25 | pub fn new(schedule: Schedule, args: Vec) -> Job { 26 | Job { 27 | job_id: Uuid::new_v4(), 28 | schedule, 29 | cmd_args: args, 30 | limit_missed_runs: 1, 31 | last_tick: None, 32 | } 33 | } 34 | 35 | fn run(&self) { 36 | let args: Vec<&str> = self.cmd_args[1..].iter().map(|s| &s[..]).collect(); 37 | let ctx = ThreadSafeContext::new(); 38 | let tctx = ctx.lock(); 39 | tctx.log_notice(&format!( 40 | " run: job_id={}; schedule={}; cmd={};", 41 | self.job_id, self.schedule, self.cmd_args[0] 42 | )); 43 | tctx.call(&self.cmd_args[0], &args).unwrap(); 44 | } 45 | 46 | fn tick(&mut self) { 47 | let now = Utc::now(); 48 | if self.last_tick.is_none() { 49 | self.last_tick = Some(now); 50 | return; 51 | } 52 | 53 | if self.limit_missed_runs > 0 { 54 | for event in self 55 | .schedule 56 | .after(&self.last_tick.unwrap()) 57 | .take(self.limit_missed_runs) 58 | { 59 | if event > now { 60 | break; 61 | } 62 | self.run(); 63 | } 64 | } else { 65 | for event in self.schedule.after(&self.last_tick.unwrap()) { 66 | if event > now { 67 | break; 68 | } 69 | self.run(); 70 | } 71 | } 72 | 73 | self.last_tick = Some(now); 74 | } 75 | 76 | #[allow(dead_code)] 77 | pub fn limit_missed_runs(&mut self, limit: usize) { 78 | self.limit_missed_runs = limit; 79 | } 80 | 81 | #[allow(dead_code)] 82 | pub fn last_tick(&mut self, last_tick: Option>) { 83 | self.last_tick = last_tick; 84 | } 85 | } 86 | 87 | #[derive(Default)] 88 | pub struct JobScheduler { 89 | jobs: Vec, 90 | } 91 | 92 | impl JobScheduler { 93 | pub fn new() -> JobScheduler { 94 | JobScheduler { jobs: Vec::new() } 95 | } 96 | 97 | pub fn add(&mut self, job: Job) -> Uuid { 98 | let job_id = job.job_id; 99 | self.jobs.push(job); 100 | 101 | job_id 102 | } 103 | 104 | pub fn remove(&mut self, job_id: Uuid) -> bool { 105 | let mut found_index = None; 106 | for (i, job) in self.jobs.iter().enumerate() { 107 | if job.job_id == job_id { 108 | found_index = Some(i); 109 | break; 110 | } 111 | } 112 | 113 | if found_index.is_some() { 114 | self.jobs.remove(found_index.unwrap()); 115 | } 116 | 117 | found_index.is_some() 118 | } 119 | 120 | pub fn clear_jobs(&mut self) { 121 | self.jobs.clear() 122 | } 123 | 124 | pub fn list_jobs(&self) -> Vec { 125 | let mut res = Vec::with_capacity(self.jobs.len()); 126 | for job in &self.jobs { 127 | res.push(JobStr { 128 | job_id: job.job_id.to_string(), 129 | schedule: job.schedule.to_string(), 130 | cmd_args: job.cmd_args.join(" ").to_string(), 131 | }) 132 | } 133 | 134 | return res; 135 | } 136 | 137 | pub fn tick(&mut self) { 138 | for job in &mut self.jobs { 139 | job.tick(); 140 | } 141 | } 142 | 143 | #[allow(dead_code)] 144 | pub fn time_till_next_job(&self) -> std::time::Duration { 145 | if self.jobs.is_empty() { 146 | // Take a guess if there are no jobs. 147 | return std::time::Duration::from_millis(500); 148 | } 149 | 150 | let mut duration = Duration::zero(); 151 | let now = Utc::now(); 152 | for job in self.jobs.iter() { 153 | for event in job.schedule.upcoming(offset::Utc).take(1) { 154 | let d = event - now; 155 | if duration.is_zero() || d < duration { 156 | duration = d; 157 | } 158 | } 159 | } 160 | 161 | duration.to_std().unwrap() 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate redis_module; 3 | 4 | use lazy_static::lazy_static; 5 | use redis_module::{Context, RedisError, RedisResult, RedisValue, Status}; 6 | use std::sync::atomic::{AtomicBool, Ordering}; 7 | use std::sync::{Arc, Mutex}; 8 | use std::{thread, time}; 9 | 10 | mod job_scheduler; 11 | use crate::job_scheduler::{Job, JobScheduler, Uuid}; 12 | 13 | static mut TICK_THREAD: Option> = None; 14 | const SCHED_SLEEP_MS: u64 = 500; 15 | 16 | lazy_static! { 17 | static ref SCHED: Mutex = Mutex::new(JobScheduler::new()); 18 | static ref TICK_THREAD_STOP: Arc = Arc::new(AtomicBool::new(false)); 19 | } 20 | 21 | fn cron_schedule(ctx: &Context, args: Vec) -> RedisResult { 22 | if args.len() < 3 { 23 | return Err(RedisError::WrongArity); 24 | } 25 | ctx.auto_memory(); 26 | 27 | let job_id = SCHED 28 | .lock() 29 | .unwrap() 30 | .add(Job::new(args[1].parse()?, args[2..].to_vec())) 31 | .to_string(); 32 | 33 | return Ok(job_id.into()); 34 | } 35 | 36 | fn cron_unschedule(ctx: &Context, args: Vec) -> RedisResult { 37 | if args.len() != 2 { 38 | return Err(RedisError::WrongArity); 39 | } 40 | ctx.auto_memory(); 41 | 42 | let job_id = match Uuid::parse_str(&args[1]) { 43 | Ok(v) => v, 44 | // return 0 if UUID is invalid 45 | Err(_err) => return Ok(RedisValue::Integer(false.into())), 46 | }; 47 | 48 | let present = SCHED.lock().unwrap().remove(job_id); 49 | 50 | return Ok(RedisValue::Integer(present.into())); 51 | } 52 | 53 | fn cron_list(ctx: &Context, args: Vec) -> RedisResult { 54 | if args.len() != 1 { 55 | return Err(RedisError::WrongArity); 56 | } 57 | ctx.auto_memory(); 58 | 59 | let jobs = SCHED.lock().unwrap().list_jobs(); 60 | let mut response = Vec::with_capacity(jobs.len()); 61 | for job in jobs { 62 | response.push(RedisValue::Array(vec![ 63 | RedisValue::SimpleString(job.job_id.into()), 64 | RedisValue::SimpleString(job.schedule.into()), 65 | RedisValue::SimpleString(job.cmd_args.into()), 66 | ])) 67 | } 68 | 69 | return Ok(RedisValue::Array(response.into())); 70 | } 71 | 72 | fn init(ctx: &Context, _: &Vec) -> Status { 73 | // TODO: load schedules and commands from stored RDB file 74 | // if available. 75 | if TICK_THREAD_STOP.load(Ordering::SeqCst) { 76 | // if the thread is already stopped, return success 77 | return Status::Ok; 78 | } 79 | 80 | unsafe { 81 | TICK_THREAD = Some(thread::spawn(move || loop { 82 | SCHED.lock().unwrap().tick(); 83 | if TICK_THREAD_STOP.load(Ordering::SeqCst) { 84 | return; 85 | } 86 | thread::sleep(time::Duration::from_millis(SCHED_SLEEP_MS)); 87 | })); 88 | } 89 | ctx.log_notice("spawned tick thread"); 90 | 91 | Status::Ok 92 | } 93 | 94 | fn deinit(ctx: &Context) -> Status { 95 | TICK_THREAD_STOP.store(true, Ordering::SeqCst); 96 | ctx.log_notice("signalled tick thread to stop"); 97 | 98 | ctx.log_notice("waiting for tick thread to stop"); 99 | unsafe { 100 | match TICK_THREAD.take().unwrap().join() { 101 | Ok(_) => ctx.log_notice("tick thread stopped gracefully"), 102 | Err(_) => ctx.log_warning("tick thread panicked"), 103 | } 104 | TICK_THREAD = None; 105 | } 106 | TICK_THREAD_STOP.store(false, Ordering::SeqCst); 107 | 108 | // clear all jobs; this can be made optional on future 109 | SCHED.lock().unwrap().clear_jobs(); 110 | 111 | Status::Ok 112 | } 113 | 114 | redis_module! { 115 | name: "cron", 116 | version: 1, 117 | data_types: [], 118 | init: init, 119 | deinit: deinit, 120 | commands: [ 121 | ["cron.schedule", cron_schedule, "write deny-oom", 0, 0, 0], 122 | ["cron.unschedule", cron_unschedule, "write deny-oom", 0, 0, 0], 123 | ["cron.list", cron_list, "readonly", 0, 0, 0], 124 | ], 125 | } 126 | --------------------------------------------------------------------------------