From 47aba2ff9dfed5ff43d2f1bac209b15dc0553f4b Mon Sep 17 00:00:00 2001 From: Alexandre Morignot <erdnaxeli@cervoi.se> Date: Sat, 12 Dec 2020 15:11:54 +0100 Subject: [PATCH] Init --- .editorconfig | 9 +++ .gitignore | 9 +++ .travis.yml | 6 ++ LICENSE | 21 ++++++ Makefile | 17 +++++ README.md | 61 +++++++++++++++++ shard.yml | 14 ++++ spec/marmot_spec.cr | 163 ++++++++++++++++++++++++++++++++++++++++++++ spec/spec_helper.cr | 2 + src/marmot.cr | 160 +++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 462 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 shard.yml create mode 100644 spec/marmot_spec.cr create mode 100644 spec/spec_helper.cr create mode 100644 src/marmot.cr diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bbd4a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf + +# Libraries don't need dependency lock +# Dependencies will be locked in applications that use them +/shard.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..765f0e9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: crystal + +# Uncomment the following if you'd like Travis to run specs and check code formatting +# script: +# - crystal spec +# - crystal tool format --check diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b771843 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 your-name-here + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1cbadcc --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +all: + shards build --release + +doc: + crystal doc + +init-dev: + shards install + +lint: + crystal tool format + ./bin/ameba src spec + +test: + crystal spec --error-trace + +.PHONY: test diff --git a/README.md b/README.md new file mode 100644 index 0000000..824731b --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# marmot + +Marmot is a scheduler, use it to schedule tasks. + +## Installation + +1. Add the dependency to your `shard.yml`: + + ```yaml + dependencies: + marmot: + github: erdnaxeli/marmot + ``` + +2. Run `shards install` + +## Usage + +```crystal +require "marmot" + +repetitions + +# This task will repeat every 15 minutes. +repeat_task = Marmot.repeat(15.minutes) { puts Time.local } +# This task will run every day at 15:28:43, and will cancel the previous task. +Marmot.cron(hour: 15, minute: 28, second: 43) do + puts "It is 15:28:43: #{Time.local}" + repeat_task.cancel +end + +times = 0 +# This task will run every 10 seconds and will cancel itself after 10 runs. +Marmot.repeat(10.seconds) do |task| + times += 1 + puts "#{times} times" + task.cancel if times = 10 +end + +# Start the scheduler. +Marmot.run +``` + +## Development + +Don't forget to run the test. + +As they deal with timing, they could fail if your computer is busy. +Do not hesitate to run then many times if that happens. + +## Contributing + +1. Fork it (<https://github.com/erdnaxeli/marmot/fork>) +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +## Contributors + +- [erdnaxeli](https://github.com/erdnaxeli) - creator and maintainer diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..f7ad10f --- /dev/null +++ b/shard.yml @@ -0,0 +1,14 @@ +name: marmot +version: 0.1.0 + +authors: + - Alexandre Morignot <erdnaxeli@cervoi.se> + +crystal: 0.35.1 + +license: MIT + +development_dependencies: + ameba: + github: crystal-ameba/ameba + version: ~>0.13.2 diff --git a/spec/marmot_spec.cr b/spec/marmot_spec.cr new file mode 100644 index 0000000..f3705e8 --- /dev/null +++ b/spec/marmot_spec.cr @@ -0,0 +1,163 @@ +require "./spec_helper" + +def expect_channel_eq(channel, value) + select + when x = channel.receive + x.should eq(value) + else + raise "A value was expected" + end +end + +def expect_channel_be(channel, value) + select + when x = channel.receive + x.should be(value) + else + raise "A value was expected" + end +end + +describe Marmot do + describe "#repeat" do + it "schedules a new task that repeats" do + channel = Channel(Int32).new + + x = 0 + task = Marmot.repeat(10.milliseconds) do + x += 1 + channel.send(x) + end + spawn Marmot.run + + sleep 15.milliseconds + expect_channel_eq(channel, 1) + sleep 10.milliseconds + expect_channel_eq(channel, 2) + sleep 10.milliseconds + expect_channel_eq(channel, 3) + + task.cancel + channel.close + Marmot.stop + end + + it "runs on first run if specified" do + channel = Channel(Int32).new + + x = 0 + task = Marmot.repeat(10.milliseconds, true) do + x += 1 + channel.send(x) + end + spawn Marmot.run + + sleep 5.milliseconds + expect_channel_eq(channel, 1) + + task.cancel + channel.close + Marmot.stop + end + end + + describe "#cron" do + it "schedules a new task" do + channel = Channel(Int32).new + + time = Time.local.at_beginning_of_second + 2.second + task = Marmot.cron(time.hour, time.minute, time.second) { channel.send(1) } + spawn Marmot.run + + sleep (time - Time.local + 5.milliseconds) + expect_channel_eq(channel, 1) + + task.cancel + channel.close + Marmot.stop + end + end + + describe "#run" do + it "runs a task without arguments" do + channel = Channel(Int32).new + + task = Marmot.repeat(3.milliseconds) { channel.send(1) } + spawn Marmot.run + + sleep 5.milliseconds + expect_channel_eq(channel, 1) + + task.cancel + channel.close + Marmot.stop + end + + it "runs a task with one argument and gives it its Task object" do + channel = Channel(Marmot::Task).new + + task = Marmot.repeat(3.milliseconds) { |t| channel.send(t) } + spawn Marmot.run + + sleep 5.milliseconds + expect_channel_be(channel, task) + + task.cancel + channel.close + Marmot.stop + end + + it "stops canceled tasks" do + channel = Channel(Marmot::Task).new + + task = Marmot.repeat(3.milliseconds) do |t| + t.cancel + channel.close + end + spawn Marmot.run + + sleep 5.milliseconds + channel.closed?.should be_true + task.canceled?.should be_true + + Marmot.stop + end + + it "does not run when there is no tasks" do + channel = Channel(Int32).new + + task = Marmot.repeat(1.milliseconds) { } + task.cancel + + spawn do + Marmot.run + channel.send(1) + end + + Fiber.yield + expect_channel_eq(channel, 1) + channel.close + end + end + + describe "#stop" do + it "stops the tasks but does not cancel them" do + channel = Channel(Int32).new + + task = Marmot.repeat(10.milliseconds) { channel.send(1) } + spawn do + Marmot.run + channel.send(2) + end + + sleep 15.milliseconds + expect_channel_eq(channel, 1) + + Marmot.stop + Fiber.yield + expect_channel_eq(channel, 2) + + task.canceled?.should be_false + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..6b7f271 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/marmot" diff --git a/src/marmot.cr b/src/marmot.cr new file mode 100644 index 0000000..4273b72 --- /dev/null +++ b/src/marmot.cr @@ -0,0 +1,160 @@ +# Marmot is a scheduler, use it to schedule tasks. +module Marmot + VERSION = "0.1.0" + + alias Callback = Proc(Task, Nil) + + @@tasks = Array(Task).new + @@stopped = true + @@stop_channel = Channel(Nil).new + + abstract class Task + @canceled = false + @callback : Callback = ->(t : Task) {} + + getter tick = Channel(Task).new + + # :nodoc: + abstract def start : Nil + + # Cancels the task. + # + # A canceled task cannot be uncanceled. + def cancel + @canceled = true + end + + # Returns true if the task is canceled. + def canceled? + @canceled + end + + # :nodoc: + def run : Nil + @callback.call(self) + rescue + end + end + + class Repeat < Task + def initialize(@span : Time::Span, @first_run : Bool, @callback : Callback) + end + + def start : Nil + spawn do + while !canceled? + if @first_run + @first_rune = false + else + sleep @span + end + + # The task could have been canceled while we were sleeping. + if !canceled? + @tick.send(self) + end + end + + @tick.close + end + end + end + + class Cron < Task + def initialize(@hour : Int32, @minute : Int32, @second : Int32, @callback : Callback) + end + + def start : Nil + spawn do + while !@canceled + sleep span + @tick.send(self) + end + end + end + + private def span + # We want the next minute, we skip the current one. + time = Time.local.at_beginning_of_second + 1.second + + if time.second < @second + time += (@second - time.second).second + elsif time.second > @second + time += (60 - time.second + @second).second + end + + if time.minute < @minute + time += (@minute - time.minute).minute + elsif time.minute > @minute + time += (60 - time.minute + @minute).minute + end + + if time.hour < @hour + time += (@hour - time.hour).hour + elsif time.hour > @hour + time += (24 - time.hour + @hour).hour + end + + time - Time.local + end + end + + extend self + + # Runs a task every given *span*. + # + # If first run is true, it will run as soon as the scheduler runs. + # Else it will wait *span* time, then run a first time. + def repeat(span : Time::Span, first_run = false, &block : Callback) : Task + task = Repeat.new(span, first_run, block) + @@tasks << task + task + end + + # Runs a task every day at *hour* and *minute*. + def cron(hour, minute, second = 0, &block : Callback) : Task + task = Cron.new(hour, minute, second, block) + @@tasks << task + task + end + + # Starts scheduling the tasks. + # + # This blocks until `#stop` is called. + def run : Nil + @@stopped = false + @@stop_channel = Channel(Nil).new + remove_canceled_tasks + + if @@tasks.size == 0 + return + end + + @@tasks.map(&.start) + + while !@@stopped + begin + m = Channel.receive_first([@@stop_channel] + @@tasks.map(&.tick)) + rescue Channel::ClosedError + break + end + + if m.is_a?(Task) + m.run + end + remove_canceled_tasks + end + end + + # Stops scheduling the tasks. + def stop + if !@@stopped + @@stopped = true + @@stop_channel.close + end + end + + private def remove_canceled_tasks + @@tasks.reject! { |task| task.canceled? } + end +end