├── .github └── workflows │ └── CI.yml ├── .gitignore ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── pipeline_spec.cr ├── spec_helper.cr └── tasker_spec.cr └── src ├── tasker.cr └── tasker ├── cron.cr ├── future.cr ├── one_shot.cr ├── pipeline.cr ├── repeat.cr ├── repeating_task.cr ├── task.cr ├── timeout.cr └── timer.cr /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | pull_request: 5 | workflow_dispatch: 6 | schedule: 7 | - cron: "0 6 * * 1" 8 | jobs: 9 | build: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [ubuntu-latest] 14 | crystal: 15 | - latest 16 | runs-on: ${{ matrix.os }} 17 | container: crystallang/crystal:${{ matrix.crystal }}-alpine 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Install dependencies 21 | run: shards install --ignore-crystal-version 22 | - name: Lint 23 | run: ./bin/ameba 24 | - name: Format 25 | run: crystal tool format --check 26 | - name: Run tests 27 | run: crystal spec -v --error-trace 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc 2 | lib 3 | bin 4 | .crystal 5 | .shards 6 | app 7 | *.dwarf 8 | *.lock 9 | bin/ameba 10 | *.DS_Store 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Stephen von Takach 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tasker 2 | 3 | [![Build Status](https://github.com/spider-gazelle/tasker/actions/workflows/CI.yml/badge.svg?branch=master)](https://github.com/spider-gazelle/tasker/actions/workflows/CI.yml) 4 | 5 | A high precision scheduler for crystal lang. 6 | Allows you to schedule tasks to run in the future and obtain the results. 7 | 8 | Usage 9 | ===== 10 | 11 | At a time in the future 12 | 13 | ```crystal 14 | Tasker.at(20.seconds.from_now) { perform_action } 15 | 16 | # If you would like the value of that result 17 | # returns value or raises error - a Future 18 | Tasker.at(20.seconds.from_now) { perform_action }.get 19 | ``` 20 | 21 | After some period of time 22 | 23 | ```crystal 24 | Tasker.in(20.seconds) { perform_action } 25 | ``` 26 | 27 | Repeating every time period 28 | 29 | ```crystal 30 | task = Tasker.every(2.milliseconds) { perform_action } 31 | # Canceling stops the schedule from running 32 | task.cancel 33 | # Resume can be used to restart a canceled schedule 34 | task.resume 35 | ``` 36 | 37 | You can grab the values of repeating schedules too 38 | 39 | ```crystal 40 | tick = 0 41 | task = Tasker.every(2.milliseconds) { tick += 1; tick } 42 | 43 | # Calling get will pause until after the next schedule has run 44 | task.get == 1 # => true 45 | task.get == 2 # => true 46 | task.get == 3 # => true 47 | 48 | # It also works as an enumerable 49 | # NOTE:: this will only stop counting once the schedule is canceled 50 | task.each do |count| 51 | puts "The count is #{count}" 52 | task.cancel if count > 5 53 | end 54 | ``` 55 | 56 | Running a CRON job 57 | 58 | ```crystal 59 | # Run a job at 7:30am every day 60 | Tasker.cron("30 7 * * *") { perform_action } 61 | 62 | # For running in a job in a particular time zone: 63 | berlin = Time::Location.load("Europe/Berlin") 64 | Tasker.cron("30 7 * * *", berlin) { perform_action } 65 | 66 | # Also supports pause, resume and enumeration 67 | ``` 68 | 69 | Timeout an operation 70 | NOTE:: technically the operation isn't cancelled on timeout as there is no fiber cancel in crystal yet / no way to unwind stack consistently 71 | 72 | ```crystal 73 | # Run some code that is expected to complete within a certain time period 74 | result = Tasker.timeout(10.seconds) { perform_action } 75 | ``` 76 | 77 | ## Pipelines 78 | 79 | a non-blocking, asynchronous pipeline where each step only processes the input if it's not already processing the previous input. 80 | 81 | ```crystal 82 | pipeline = Tasker::Pipeline(Input, Output).new("name") do |input| 83 | process(input) # => Output 84 | end 85 | 86 | pipeline.chain { |output_of_first_step| 87 | next_step(output_of_first_step) 88 | }.subscribe { |output| 89 | # a subscribe step is always run, even if it's already running 90 | publish output 91 | } 92 | ``` 93 | 94 | The idea is to maximise throughput with minimal latency. 95 | Make sure to use the `-Dpreview_mt` flag when building. 96 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: tasker 2 | version: 2.1.4 3 | crystal: ">= 0.36.1" 4 | 5 | dependencies: 6 | cron_parser: 7 | github: kostya/cron_parser 8 | future: 9 | github: crystal-community/future.cr 10 | 11 | development_dependencies: 12 | ameba: 13 | github: veelenga/ameba 14 | -------------------------------------------------------------------------------- /spec/pipeline_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Tasker do 4 | it "should perform tasks in a pipeline" do 5 | add_invoke = 0 6 | multi_invoke = 0 7 | sub_invoke = 0 8 | result = 0.0 9 | 10 | pipeline = Tasker::Pipeline(Int32, Int32).new("name") { |input| 11 | sleep 100.milliseconds 12 | input 13 | } 14 | 15 | pipeline.chain { |input| 16 | sleep 200.milliseconds 17 | add_invoke += 1 18 | (input + 1).to_f 19 | }.chain { |input| 20 | sleep 300.milliseconds 21 | multi_invoke += 1 22 | input * 3 23 | }.subscribe { |output| 24 | sub_invoke += 1 25 | result = output 26 | } 27 | 28 | 50.times do 29 | sleep 50.milliseconds 30 | pipeline.process 100 31 | end 32 | 33 | sleep 1.second 34 | 35 | sub_invoke.should eq multi_invoke 36 | (multi_invoke < add_invoke).should be_true 37 | 38 | result.should eq 303.0 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/tasker" 3 | 4 | if ENV["CI"]? 5 | ::Log.setup("*", :trace) 6 | 7 | Spec.before_suite do 8 | ::Log.builder.bind("*", backend: ::Log::IOBackend.new(STDOUT), level: ::Log::Severity::Trace) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/tasker_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Tasker do 4 | tasks = [] of Tasker::Task 5 | ran = 0 6 | 7 | Spec.before_each do 8 | begin 9 | tasks.each &.cancel 10 | Fiber.yield 11 | tasks.clear 12 | GC.collect 13 | rescue error 14 | tasks = [] of Tasker::Task 15 | puts "\nfailed cancel running tasks\n#{error.inspect_with_backtrace}" 16 | end 17 | ran = 0 18 | end 19 | 20 | it "should work with sets" do 21 | sched = Tasker.instance 22 | 23 | time = 2.milliseconds.from_now 24 | task1 = sched.at(time) { nil } 25 | task2 = sched.at(time) { nil } 26 | 27 | set = Set(Tasker::Task).new 28 | set << task1 29 | set << task2 30 | 31 | tasks << task1 32 | tasks << task2 33 | 34 | set.size.should eq(2) 35 | 36 | set.delete(task1) 37 | set.size.should eq(1) 38 | end 39 | 40 | it "should work with arrays" do 41 | sched = Tasker.instance 42 | 43 | time = 2.milliseconds.from_now 44 | task1 = sched.at(time) { nil } 45 | task2 = sched.at(time) { nil } 46 | 47 | tasks << task1 48 | tasks << task2 49 | set = [task1, task2] 50 | 51 | set.size.should eq(2) 52 | 53 | set.delete(task1) 54 | set.size.should eq(1) 55 | end 56 | 57 | it "should schedule a task to run in the future" do 58 | sched = Tasker.instance 59 | ran = 0 60 | tasks << sched.at(40.milliseconds.from_now) { ran = 1 } 61 | 62 | sleep 20.milliseconds 63 | ran.should eq(0) 64 | 65 | sleep 30.milliseconds 66 | ran.should eq(1) 67 | end 68 | 69 | it "should cancel a scheduled task" do 70 | sched = Tasker.instance 71 | ran = 0 72 | task = sched.at(40.milliseconds.from_now) { ran = 1 } 73 | tasks << task 74 | 75 | sleep 20.milliseconds 76 | task.cancel 77 | 78 | # wait until the task should have run 79 | sleep 40.milliseconds 80 | ran.should eq(0) 81 | end 82 | 83 | it "should cancel only the specified task" do 84 | sched = Tasker.instance 85 | ran = 0 86 | 87 | time = 40.milliseconds.from_now 88 | task1 = sched.at(time) { ran += 1 } 89 | tasks << sched.at(time) { ran += 1 } 90 | tasks << task1 91 | 92 | sleep 20.milliseconds 93 | task1.cancel 94 | 95 | sleep 30.milliseconds 96 | ran.should eq(1) 97 | end 98 | 99 | it "should run both a single task and a repeating task" do 100 | sched = Tasker.instance 101 | ran = 0 102 | 103 | sched.in(20.milliseconds) { ran += 1 } 104 | task2 = sched.every(40.milliseconds) { ran += 1 } 105 | 106 | sleep 100.milliseconds 107 | task2.cancel 108 | ran.should eq(3) 109 | end 110 | 111 | it "should schedule a task to run after a period of time" do 112 | sched = Tasker.instance 113 | ran = 0 114 | tasks << sched.in(40.milliseconds) { ran = 1 } 115 | 116 | sleep 20.milliseconds 117 | ran.should eq(0) 118 | 119 | sleep 30.milliseconds 120 | ran.should eq(1) 121 | end 122 | 123 | it "should be possible to obtain the return value of the task" do 124 | sched = Tasker.instance 125 | 126 | # Test execution 127 | task = sched.at(2.milliseconds.from_now) { true } 128 | tasks << task 129 | task.get.should eq true 130 | 131 | # Test failure 132 | task = sched.at(2.milliseconds.from_now) { raise "was error" } 133 | tasks << task 134 | begin 135 | task.get 136 | raise "not here" 137 | rescue error 138 | error.message.should eq "was error" 139 | end 140 | 141 | # Test cancelation 142 | task = sched.at(2.milliseconds.from_now) { true } 143 | tasks << task 144 | spawn(same_thread: true) { task.cancel } 145 | begin 146 | task.get 147 | raise "failed" 148 | rescue error 149 | error.message.should eq "Task canceled" 150 | end 151 | end 152 | 153 | it "should schedule a repeating task" do 154 | sched = Tasker.instance 155 | repeat_count = 0 156 | task = sched.every(40.milliseconds) { repeat_count += 1 } 157 | 158 | begin 159 | tasks << task 160 | 161 | sleep 10.milliseconds 162 | repeat_count.should eq(0) 163 | 164 | sleep 50.milliseconds 165 | repeat_count.should eq(1) 166 | 167 | sleep 40.milliseconds 168 | repeat_count.should eq(2) 169 | 170 | sleep 40.milliseconds 171 | repeat_count.should eq(3) 172 | rescue error 173 | puts "\nfailed cancel running tasks\n#{error.inspect_with_backtrace}" 174 | ensure 175 | task.cancel 176 | end 177 | end 178 | 179 | it "should pause and resume a repeating task" do 180 | sched = Tasker.instance 181 | run_count = 0 182 | task = sched.every(80.milliseconds) { run_count += 1; run_count } 183 | 184 | begin 185 | tasks << task 186 | 187 | sleep 100.milliseconds 188 | run_count.should eq(1) 189 | 190 | sleep 80.milliseconds 191 | run_count.should eq(2) 192 | 193 | task.cancel 194 | 195 | sleep 80.milliseconds 196 | run_count.should eq(2) 197 | 198 | task.resume 199 | 200 | sleep 100.milliseconds 201 | run_count.should eq(3) 202 | rescue error 203 | puts "\nfailed cancel running tasks\n#{error.inspect_with_backtrace}" 204 | ensure 205 | task.cancel 206 | end 207 | end 208 | 209 | it "should be possible to obtain the next value of a repeating" do 210 | sched = Tasker.instance 211 | ran = 0 212 | task = sched.every(2.milliseconds) do 213 | ran += 1 214 | raise "some error" if ran == 4 215 | ran 216 | end 217 | 218 | begin 219 | tasks << task 220 | 221 | # Test execution 222 | task.get.should eq 1 223 | task.get.should eq 2 224 | task.get.should eq 3 225 | begin 226 | task.get.should eq 4 227 | raise "failed" 228 | rescue error 229 | error.message.should eq "some error" 230 | end 231 | task.get.should eq 5 232 | 233 | # Test cancelation 234 | spawn(same_thread: true) { task.cancel } 235 | begin 236 | task.get 237 | raise "failed" 238 | rescue error 239 | error.message.should eq "Task canceled" 240 | end 241 | rescue error 242 | puts "\nfailed cancel running tasks\n#{error.inspect_with_backtrace}" 243 | ensure 244 | task.cancel 245 | end 246 | end 247 | 248 | it "should act like an enumerable" do 249 | sched = Tasker.instance 250 | ran = 0 251 | task = sched.every(2.milliseconds) do 252 | ran += 1 253 | raise "other error" if ran == 4 254 | ran 255 | end 256 | 257 | begin 258 | tasks << task 259 | 260 | results = [] of Int32 261 | begin 262 | task.each { |result| results << result } 263 | raise "failed with #{results}" 264 | rescue error 265 | error.message.should eq "other error" 266 | end 267 | results.should eq [1, 2, 3] 268 | rescue error 269 | puts "\nfailed cancel running tasks\n#{error.inspect_with_backtrace}" 270 | ensure 271 | task.cancel 272 | end 273 | end 274 | 275 | # We calculate what the next minute is and then wait for it to roll by 276 | # If it takes too long then we fail it 277 | it "should schedule a CRON task" do 278 | sched = Tasker.instance 279 | time = Time.local 280 | minute = time.minute + 1 281 | minute = 0 if minute == 60 282 | ran = 0 283 | task = sched.cron("#{minute} * * * *") { ran = 1 } 284 | begin 285 | tasks << task 286 | 287 | seconds = ((60 - time.second) // 2).seconds 288 | sleep seconds 289 | ran.should eq(0) 290 | 291 | sleep(seconds + 1.second) 292 | ran.should eq(1) 293 | rescue error 294 | puts "\nfailed cancel running tasks\n#{error.inspect_with_backtrace}" 295 | ensure 296 | task.cancel 297 | end 298 | end 299 | 300 | it "should timeout an operation" do 301 | expect_raises(Tasker::Timeout) do 302 | Tasker.timeout(100.milliseconds) { sleep 200.milliseconds } 303 | end 304 | end 305 | 306 | it "should return the result of a timeout operation" do 307 | result = Tasker.timeout(100.milliseconds) { 34 } 308 | result.should eq 34 309 | 310 | result = Tasker.timeout(-100.milliseconds) { "quick" } 311 | result.should eq "quick" 312 | end 313 | 314 | it "should propagate errors" do 315 | expect_raises(Channel::ClosedError) do 316 | Tasker.timeout(100.milliseconds) { raise Channel::ClosedError.new("testing"); 34 } 317 | end 318 | end 319 | end 320 | -------------------------------------------------------------------------------- /src/tasker.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | 3 | class Tasker 4 | Log = ::Log.for("tasker") 5 | 6 | module Methods 7 | # Creates a once off task that occurs at a particular date and time 8 | def at(time : Time, &callback : -> _) : Tasker::OneShot 9 | Tasker::OneShot.new(time, &callback).schedule.as(Tasker::OneShot) 10 | end 11 | 12 | # Creates a once off task that occurs in the future 13 | def in(span : Time::Span, &callback : -> _) : Tasker::OneShot 14 | Tasker::OneShot.new(span.from_now, &callback).schedule.as(Tasker::OneShot) 15 | end 16 | 17 | # Creates repeating task 18 | # Schedules the repeat after executing the task 19 | def every(span : Time::Span, &callback : -> _) : Tasker::Repeat 20 | Tasker::Repeat.new(span, &callback).schedule.as(Tasker::Repeat) 21 | end 22 | 23 | # Create a repeating event that uses a CRON line to determine the trigger time 24 | def cron(line : String, timezone = Time::Location.local, &callback : -> _) : Tasker::CRON 25 | Tasker::CRON.new(line, timezone, &callback).schedule.as(Tasker::CRON) 26 | end 27 | 28 | def timeout(period : Time::Span, same_thread : Bool = true, &callback : -> _) 29 | Tasker::TimeoutHander.new(period, same_thread, &callback).execute! 30 | end 31 | end 32 | 33 | @@default : Tasker? 34 | 35 | def self.instance : Tasker 36 | scheduler = @@default 37 | return scheduler if scheduler 38 | @@default = Tasker.new 39 | end 40 | 41 | include Methods 42 | extend Methods 43 | end 44 | 45 | require "./tasker/*" 46 | -------------------------------------------------------------------------------- /src/tasker/cron.cr: -------------------------------------------------------------------------------- 1 | require "cron_parser" 2 | require "./repeating_task" 3 | 4 | class Tasker::CRON(R) < Tasker::RepeatingTask(R) 5 | def initialize(cron, @location : Time::Location, &block : -> R) 6 | @cron = CronParser.new(cron) 7 | super(&block) 8 | end 9 | 10 | property location : Time::Location 11 | getter next_scheduled : Time? 12 | 13 | def schedule 14 | return if @future.state == Future::State::Canceled 15 | @last_scheduled = @next_scheduled 16 | @next_scheduled = @cron.next(Time.local(@location)) 17 | super 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/tasker/future.cr: -------------------------------------------------------------------------------- 1 | require "future" 2 | 3 | class Tasker::Future(R) < ::Future::Compute(R) 4 | def initialize(block : -> R) 5 | super(run_immediately: false, &block) 6 | 7 | # Calling #get should never execute the future 8 | @state = State::Delayed 9 | end 10 | 11 | # The future is holding the state for our scheduled task 12 | getter state 13 | 14 | # As we are controlling the delay we need direct access to run_compute 15 | def trigger 16 | run_compute 17 | end 18 | 19 | # We also want direct access to wait without grabbing the computed response 20 | def wait_complete 21 | wait 22 | end 23 | 24 | # We'll force the state into canceled as we use this to prevent rescheduling 25 | def cancel(msg) 26 | super(msg) 27 | @state = State::Canceled 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /src/tasker/one_shot.cr: -------------------------------------------------------------------------------- 1 | require "./task" 2 | 3 | class Tasker::OneShot(R) < Tasker::Task 4 | include Enumerable(R) 5 | 6 | def initialize(at, &block : -> R) 7 | @next_scheduled = at 8 | @future = Tasker::Future(R).new(block) 9 | @created = Time.utc 10 | @trigger_count = 0_i64 11 | end 12 | 13 | getter next_scheduled : Time? 14 | 15 | def trigger 16 | return if @future.state >= Future::State::Running 17 | @last_scheduled = @next_scheduled 18 | @next_scheduled = nil 19 | @trigger_count += 1 20 | @timer.try &.cancel 21 | @timer = nil 22 | @future.trigger 23 | end 24 | 25 | def cancel(msg = "Task canceled") 26 | super(msg) 27 | return if @future.state >= Future::State::Completed 28 | @next_scheduled = nil 29 | @future.cancel(msg) 30 | end 31 | 32 | def get 33 | @future.get 34 | end 35 | 36 | def resume 37 | raise "only repeating tasks can be resumed" 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /src/tasker/pipeline.cr: -------------------------------------------------------------------------------- 1 | class Tasker 2 | module Processor(Input) 3 | abstract def process(input : Input) : Bool 4 | abstract def close : Nil 5 | abstract def closed? : Bool 6 | end 7 | 8 | class Subscription(Input) 9 | include Processor(Input) 10 | 11 | def initialize(&@work : Input -> Nil) 12 | end 13 | 14 | def process(input : Input) : Bool 15 | @work.call input 16 | true 17 | end 18 | 19 | def close : Nil 20 | end 21 | 22 | # check if the pipline is running 23 | def closed? : Bool 24 | false 25 | end 26 | end 27 | 28 | # a lossy pipeline for realtime processing so any outputs are 29 | # as up to date as possible. This means some results might be 30 | # ignored at various stages in the pipeline. 31 | class Pipeline(Input, Output) 32 | include Processor(Input) 33 | 34 | def initialize(@name : String? = nil, &@work : Input -> Output) 35 | spawn { process_loop } 36 | end 37 | 38 | @work : Proc(Input, Output) 39 | @in : Channel(Input) = Channel(Input).new 40 | @chained : Array(Processor(Output)) = [] of Processor(Output) 41 | 42 | # the time it took to perform the last bit of work 43 | getter time : Time::Span = 0.seconds 44 | 45 | # is work being performed currently 46 | getter? idle : Bool = true 47 | 48 | # name of the pipeline 49 | getter name : String? 50 | 51 | # non-blocking send 52 | def process(input : Input) : Bool 53 | select 54 | when @in.send(input) then true 55 | else 56 | false 57 | end 58 | end 59 | 60 | # push the output of this pipeline task into the input 61 | # of the next task, if that task is idle 62 | def chain(name : String? = @name, &work : Output -> _) 63 | type_var = uninitialized Output 64 | proc = Pipeline(Output, typeof(work.call(type_var))).new(name, &work) 65 | @chained << proc 66 | proc 67 | end 68 | 69 | # :ditto: 70 | def chain(task : Pipeline(Output)) 71 | @chained << task 72 | task 73 | end 74 | 75 | # push all the outputs of this task to the subscriber 76 | def subscribe(&work : Output -> Nil) 77 | proc = Subscription(Output).new(&work) 78 | @chained << proc 79 | proc 80 | end 81 | 82 | # :ditto: 83 | def subscribe(subscription : Subscription(Output)) 84 | @chained << subscription 85 | subscription 86 | end 87 | 88 | # :nodoc: 89 | def finalize 90 | close 91 | end 92 | 93 | # shutdown processing 94 | def close : Nil 95 | @in.close 96 | @chained.each(&.close) 97 | end 98 | 99 | # check if the pipline is running 100 | def closed? : Bool 101 | @in.closed? 102 | end 103 | 104 | protected def process_loop 105 | loop do 106 | return if @in.closed? 107 | begin 108 | @idle = true 109 | input = @in.receive 110 | @idle = false 111 | t1 = Time.monotonic 112 | output = @work.call input 113 | t2 = Time.monotonic 114 | @time = t2 - t1 115 | @chained.each(&.process(output)) 116 | rescue Channel::ClosedError 117 | rescue error 118 | Log.error(exception: error) { "error in pipeline #{@name}" } 119 | end 120 | end 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /src/tasker/repeat.cr: -------------------------------------------------------------------------------- 1 | require "./repeating_task" 2 | 3 | class Tasker::Repeat(R) < Tasker::RepeatingTask(R) 4 | def initialize(@period : Time::Span, &block : -> R) 5 | super(&block) 6 | end 7 | 8 | getter next_scheduled : Time? 9 | 10 | def schedule 11 | return if @future.state == Future::State::Canceled 12 | @last_scheduled = @next_scheduled 13 | @next_scheduled = @period.from_now 14 | super 15 | self 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /src/tasker/repeating_task.cr: -------------------------------------------------------------------------------- 1 | require "./task" 2 | 3 | abstract class Tasker::RepeatingTask(R) < Tasker::Task 4 | include Enumerable(R) 5 | 6 | def initialize(&block : -> R) 7 | @callback = block 8 | @future = Tasker::Future(R).new(@callback) 9 | super 10 | end 11 | 12 | getter next_scheduled : Time? 13 | 14 | private def next_future 15 | @future = Tasker::Future(R).new(@callback) 16 | end 17 | 18 | def cancel(msg = "Task canceled") 19 | super(msg) 20 | return if @future.state == Future::State::Canceled 21 | @next_scheduled = nil 22 | @future.cancel(msg) 23 | end 24 | 25 | def resume 26 | return if @future.state != Future::State::Canceled 27 | last = @last_scheduled 28 | next_future 29 | schedule 30 | @last_scheduled = last 31 | end 32 | 33 | def trigger 34 | return if @future.state >= Future::State::Running 35 | @trigger_count += 1 36 | @future.trigger 37 | ensure 38 | if @future.state != Future::State::Canceled 39 | next_future 40 | schedule 41 | end 42 | end 43 | 44 | def get 45 | @future.get 46 | end 47 | 48 | def each(&) 49 | while @future.state != Future::State::Canceled 50 | yield @future.get 51 | end 52 | rescue error : ::Future::CanceledError 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /src/tasker/task.cr: -------------------------------------------------------------------------------- 1 | require "./timer" 2 | 3 | abstract class Tasker::Task 4 | include Comparable(Tasker::Task) 5 | 6 | def initialize 7 | @created = Time.utc 8 | @trigger_count = 0_i64 9 | end 10 | 11 | @timer : Timer? 12 | 13 | getter created : Time 14 | getter trigger_count : Int64 15 | getter last_scheduled : Time? 16 | getter next_scheduled : Time? 17 | 18 | def next_epoch 19 | @next_scheduled.as(Time).to_unix_ms 20 | end 21 | 22 | # required for comparable 23 | def <=>(other) 24 | @next_scheduled.as(Time) <=> other.next_scheduled.as(Time) 25 | end 26 | 27 | def ==(other) 28 | self.object_id == other.object_id 29 | end 30 | 31 | def cancel(msg = "Task canceled") : Nil 32 | @timer.try &.cancel 33 | @timer = nil 34 | end 35 | 36 | abstract def resume 37 | abstract def trigger 38 | abstract def get 39 | 40 | def each(&) 41 | yield get 42 | end 43 | 44 | SYNC_PERIOD = 2.minutes.total_milliseconds / 1000.0_f64 45 | 46 | def schedule 47 | Log.trace { "task scheduling timer, id: #{self.object_id}" } 48 | 49 | now = Time.utc.to_unix_ms 50 | time = next_epoch 51 | period = time - now 52 | 53 | # Calculate the delay period 54 | seconds = if period < 0 55 | Log.trace { "scheduled for the past, id: #{self.object_id}" } 56 | 0.0 57 | else 58 | period.to_f64 / 1000.0_f64 59 | end 60 | 61 | @timer = timer = Timer.new(seconds) { trigger } 62 | timer.start_timer 63 | self 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /src/tasker/timeout.cr: -------------------------------------------------------------------------------- 1 | class Tasker 2 | class Timeout < Exception 3 | end 4 | 5 | struct TimeoutHander(Output) 6 | def initialize(@period : Time::Span, @same_thread : Bool = true, &@callback : -> Output) 7 | end 8 | 9 | def execute! 10 | success = Channel(Output).new(1) 11 | failure = Channel(Exception).new(1) 12 | 13 | if @same_thread 14 | fiber = Fiber.new { perform_action(success, failure) } 15 | 16 | # scheudle this fiber to run again 17 | Fiber.current.enqueue 18 | start = Time.monotonic 19 | 20 | # start the action that we want to perform 21 | fiber.resume 22 | elapsed = Time.monotonic - start 23 | else 24 | spawn { perform_action(success, failure) } 25 | elapsed = 0.seconds 26 | end 27 | 28 | # wait for the action to complete 29 | select 30 | when result = success.receive 31 | result 32 | when error = failure.receive 33 | raise error 34 | # NOTE:: the timeout won't fire if there is a result and the timeout is negative 35 | # basically this select statement works as expected 36 | when timeout(@period - elapsed) 37 | raise Timeout.new("timeout after #{@period}") 38 | end 39 | end 40 | 41 | protected def perform_action(success, failure) 42 | result = @callback.call 43 | success.send result 44 | rescue error 45 | failure.send(error) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /src/tasker/timer.cr: -------------------------------------------------------------------------------- 1 | class Timer 2 | def initialize(@sleep_for : Float64, &@callback : -> _) 3 | @cancelled = false 4 | @cancel = Channel(Bool).new(1) 5 | end 6 | 7 | def start_timer : Nil 8 | Log.trace { "timer start called, id: #{self.object_id}" } 9 | spawn(same_thread: true) { schedule_wait } 10 | Fiber.yield 11 | end 12 | 13 | def cancel : Nil 14 | return if @cancelled 15 | Log.trace { "timer cancel requested, id: #{self.object_id}" } 16 | @cancelled = true 17 | begin 18 | @cancel.send(true) 19 | rescue 20 | end 21 | end 22 | 23 | private def schedule_wait 24 | Log.trace { "timer waiting for #{@sleep_for} seconds, id: #{self.object_id}" } 25 | 26 | select 27 | when @cancel.receive 28 | Log.trace { "timer cancelled, id: #{self.object_id}" } 29 | when timeout(@sleep_for.seconds) 30 | if !@cancelled 31 | Log.trace { "timer fired, id: #{self.object_id}" } 32 | @cancelled = true 33 | @callback.call 34 | else 35 | Log.trace { "timer fired but ignored as cancelled, id: #{self.object_id}" } 36 | end 37 | end 38 | rescue error 39 | Log.warn(exception: error) { "error in tasker scheduler" } 40 | ensure 41 | @cancelled = true 42 | @cancel.close 43 | end 44 | end 45 | --------------------------------------------------------------------------------