ztimer
Advanced tools
+93
-22
@@ -1,6 +0,9 @@ | ||
| require "ztimer/version" | ||
| require "ztimer/slot" | ||
| require "ztimer/sorted_store" | ||
| require "ztimer/watcher" | ||
| # frozen_string_literal: true | ||
| require 'ztimer/version' | ||
| require 'ztimer/slot' | ||
| require 'ztimer/sorted_store' | ||
| require 'ztimer/watcher' | ||
| # Implements a timer which allows to execute a block with a delay, recurrently or asynchronously. | ||
| class Ztimer | ||
@@ -13,3 +16,3 @@ @default_instance = nil | ||
| @concurrency = concurrency | ||
| @watcher = Ztimer::Watcher.new(){|slot| execute(slot) } | ||
| @watcher = Ztimer::Watcher.new { |slot| execute(slot) } | ||
| @workers_lock = Mutex.new | ||
@@ -22,2 +25,3 @@ @count_lock = Mutex.new | ||
| # Execute the code block asyncrhonously right now | ||
| def async(&callback) | ||
@@ -30,5 +34,6 @@ enqueued_at = utc_microseconds | ||
| return slot | ||
| slot | ||
| end | ||
| # Execute the code block after the specified delay | ||
| def after(milliseconds, &callback) | ||
@@ -41,35 +46,100 @@ enqueued_at = utc_microseconds | ||
| return slot | ||
| slot | ||
| end | ||
| def every(milliseconds, &callback) | ||
| enqueued_at = utc_microseconds | ||
| expires_at = enqueued_at + milliseconds * 1000 | ||
| slot = Slot.new(enqueued_at, expires_at, milliseconds * 1000, &callback) | ||
| # Execute the code block at a specific datetime | ||
| def at(datetime, &callback) | ||
| enqueued_at = datetime.to_f * 1_000_000 | ||
| slot = Slot.new(enqueued_at, enqueued_at, -1, &callback) | ||
| add(slot) | ||
| return slot | ||
| slot | ||
| end | ||
| # Execute the code block every N milliseconds. | ||
| # When :start_at is specified, the first execution will start at specified date/time | ||
| def every(milliseconds, start_at: nil, &callback) | ||
| enqueued_at = start_at ? start_at.to_f * 1_000_000 : utc_microseconds | ||
| expires_at = enqueued_at + milliseconds * 1000 | ||
| slot = Slot.new(enqueued_at, expires_at, milliseconds * 1000, &callback) | ||
| add(slot) | ||
| slot | ||
| end | ||
| # Run ztimer every N seconds, starting with the nearest time slot (ex. secondly(5) | ||
| # will run at second 0, 5, 10, 15, etc.) | ||
| def secondly(seconds, offset: 0, &callback) | ||
| start_time = utc_microseconds | ||
| milliseconds = (seconds.to_f * 1000).to_i | ||
| enqueued_at = start_time - (start_time % (milliseconds * 1000)) + offset * 1_000_000 | ||
| expires_at = enqueued_at + milliseconds * 1000 | ||
| slot = Slot.new(enqueued_at, expires_at, milliseconds * 1000, &callback) | ||
| add(slot) | ||
| slot | ||
| end | ||
| # Run ztimer every N minutes, starting at the nearest time slot (ex. minutely(2) will run at minute 0, 2, 4, 6, etc.) | ||
| def minutely(minutes, offset: 0, &callback) | ||
| secondly(minutes.to_f * 60, offset: offset.to_f * 60, &callback) | ||
| end | ||
| # Run ztimer every N hours, starting at the nearest time slot (ex. hourly(2) will run at hour 0, 2, 4, 6, etc.) | ||
| def hourly(hours, offset: 0, &callback) | ||
| minutely(hours.to_f * 60, offset: offset.to_f * 60, &callback) | ||
| end | ||
| def daily(days, offset: 0, &callback) | ||
| raise ArgumentError, "Days number should be > 0: #{days.inspect}" if days.to_f <= 0 | ||
| hourly(days.to_f * 24, offset: offset.to_f * 24, &callback) | ||
| end | ||
| def day_of_week(day, &callback) | ||
| days = %w[sun mon tue thu wen fri sat] | ||
| current_day = Time.now.wday | ||
| index = day.to_i | ||
| if day.is_a?(String) | ||
| # Find day number by day name | ||
| index = days.index { |day_name| day.strip.downcase == day_name } | ||
| raise ArgumentError, "Invalid week day: #{day.inspect}" if index.nil? | ||
| elsif index.negative? || index > 6 | ||
| raise ArgumentError, "Invalid week day: #{day.inspect}" | ||
| end | ||
| offset = 0 | ||
| offset = (current_day > index ? index - current_day : current_day - index) if current_day != index | ||
| daily(7, offset: offset, &callback) | ||
| end | ||
| def days_of_week(*args, &callback) | ||
| args.map { |day| day_of_week(day, &callback) } | ||
| end | ||
| def jobs_count | ||
| return @watcher.jobs | ||
| @watcher.jobs | ||
| end | ||
| def concurrency=(new_value) | ||
| raise ArgumentError.new("Invalid concurrency value: #{new_value}") unless new_value.is_a?(Fixnum) && new_value >= 1 | ||
| value_is_integer = new_value.is_a?(Integer) | ||
| raise ArgumentError, "Invalid concurrency value: #{new_value}" unless value_is_integer && new_value >= 1 | ||
| @concurrency = new_value | ||
| end | ||
| def stats | ||
| { | ||
| running: @running, | ||
| running: @running, | ||
| scheduled: @watcher.jobs, | ||
| executing: @queue.size, | ||
| total: @count | ||
| total: @count | ||
| } | ||
| end | ||
| def self.method_missing(name, *args, &block) | ||
@@ -115,8 +185,9 @@ @default_instance ||= Ztimer.new(concurrency: 20) | ||
| current_slot.callback.call(current_slot) unless current_slot.callback.nil? || current_slot.canceled? | ||
| rescue => e | ||
| STDERR.puts e.inspect + (e.backtrace ? "\n" + e.backtrace.join("\n") : "") | ||
| rescue StandardError => e | ||
| backtrace = e.backtrace ? "\n#{e.backtrace.join("\n")}" : '' | ||
| warn e.inspect + backtrace | ||
| end | ||
| end | ||
| rescue ThreadError | ||
| puts "queue is empty" | ||
| puts 'queue is empty' | ||
| end | ||
@@ -129,4 +200,4 @@ @workers_lock.synchronize { @running -= 1 } | ||
| def utc_microseconds | ||
| return Time.now.to_f * 1_000_000 | ||
| Time.now.to_f * 1_000_000 | ||
| end | ||
| end |
@@ -0,3 +1,5 @@ | ||
| # frozen_string_literal: true | ||
| class Ztimer | ||
| # Implements a slot, which represents a block of code to be executed at specified time slot. | ||
| class Slot | ||
@@ -7,3 +9,3 @@ attr_reader :enqueued_at, :expires_at, :recurrency, :callback | ||
| def initialize(enqueued_at, expires_at,recurrency = -1, &callback) | ||
| def initialize(enqueued_at, expires_at, recurrency = -1, &callback) | ||
| @enqueued_at = enqueued_at | ||
@@ -19,13 +21,11 @@ @expires_at = expires_at | ||
| def recurrent? | ||
| return @recurrency > 0 | ||
| @recurrency.positive? | ||
| end | ||
| def reset! | ||
| if recurrent? | ||
| @expires_at += recurrency | ||
| end | ||
| @expires_at += recurrency if recurrent? | ||
| end | ||
| def canceled? | ||
| return @canceled | ||
| @canceled | ||
| end | ||
@@ -38,5 +38,5 @@ | ||
| def <=>(other) | ||
| return @expires_at <=> other.expires_at | ||
| @expires_at <=> other.expires_at | ||
| end | ||
| end | ||
| end |
@@ -0,5 +1,7 @@ | ||
| # frozen_string_literal: true | ||
| class Ztimer | ||
| # Implements a performant sorted store for time slots, which uses binary search to optimize | ||
| # new items insertion and items retrievement. | ||
| class SortedStore | ||
| def initialize | ||
@@ -11,3 +13,4 @@ @store = [] | ||
| @store.insert(position_for(value), value) | ||
| return self | ||
| self | ||
| end | ||
@@ -17,41 +20,36 @@ | ||
| index = index_of(value) | ||
| if index | ||
| @store.delete_at(index) | ||
| else | ||
| return nil | ||
| end | ||
| index.nil? ? nil : @store.delete_at(index) | ||
| end | ||
| def [](index) | ||
| return @store[index] | ||
| @store[index] | ||
| end | ||
| def first | ||
| return @store.first | ||
| @store.first | ||
| end | ||
| def last | ||
| return @store.last | ||
| @store.last | ||
| end | ||
| def shift | ||
| return @store.shift | ||
| @store.shift | ||
| end | ||
| def pop | ||
| return @store.pop | ||
| @store.pop | ||
| end | ||
| def index_of(value, start = 0, stop = [@store.count - 1, 0].max) | ||
| if start > stop | ||
| return nil | ||
| elsif start == stop | ||
| return value == @store[start] ? start : nil | ||
| else | ||
| position = ((stop + start)/ 2).to_i | ||
| case value <=> @store[position] | ||
| when -1 then return index_of(value, start, position) | ||
| when 0 then return position | ||
| when 1 then return index_of(value, position + 1, stop) | ||
| end | ||
| return nil if start > stop | ||
| return value == @store[start] ? start : nil if start == stop | ||
| position = ((stop + start) / 2).to_i | ||
| case value <=> @store[position] | ||
| when -1 then index_of(value, start, position) | ||
| when 0 then position | ||
| when 1 then index_of(value, position + 1, stop) | ||
| end | ||
@@ -61,36 +59,35 @@ end | ||
| def count | ||
| return @store.count | ||
| @store.count | ||
| end | ||
| def size | ||
| return @store.size | ||
| @store.size | ||
| end | ||
| def empty? | ||
| return @store.empty? | ||
| @store.empty? | ||
| end | ||
| def clear | ||
| return @store.clear | ||
| @store.clear | ||
| end | ||
| def to_a | ||
| return @store.dup | ||
| @store.dup | ||
| end | ||
| protected | ||
| def position_for(item, start = 0, stop = [@store.count - 1, 0].max) | ||
| if start > stop | ||
| raise "Invalid range (#{start}, #{stop})" | ||
| elsif start == stop | ||
| raise "Invalid range (#{start}, #{stop})" if start > stop | ||
| if start == stop | ||
| element = @store[start] | ||
| return element.nil? || ((item <=> element) <= 0) ? start : start + 1 | ||
| element.nil? || ((item <=> element) <= 0) ? start : start + 1 | ||
| else | ||
| position = ((stop + start)/ 2).to_i | ||
| position = ((stop + start) / 2).to_i | ||
| case item <=> @store[position] | ||
| when -1 then return position_for(item, start, position) | ||
| when 0 then return position | ||
| when 1 then return position_for(item, position + 1, stop) | ||
| when -1 then position_for(item, start, position) | ||
| when 0 then position | ||
| when 1 then position_for(item, position + 1, stop) | ||
| end | ||
@@ -97,0 +94,0 @@ end |
@@ -0,3 +1,5 @@ | ||
| # frozen_string_literal: true | ||
| class Ztimer | ||
| VERSION = "0.6.0" | ||
| VERSION = '1.0.0' | ||
| end |
+15
-14
@@ -0,5 +1,7 @@ | ||
| # frozen_string_literal: true | ||
| class Ztimer | ||
| # Implements a watcher which allows to enqueue Ztimer::Slot items, that will be executed | ||
| # as soon as the time of Ztimer::Slot is reached. | ||
| class Watcher | ||
| def initialize(&callback) | ||
@@ -13,8 +15,6 @@ @thread = nil | ||
| def << (slot) | ||
| def <<(slot) | ||
| @mutex.synchronize do | ||
| @slots << slot | ||
| if @slots.first == slot | ||
| run | ||
| end | ||
| run if @slots.first == slot | ||
| end | ||
@@ -24,3 +24,3 @@ end | ||
| def jobs | ||
| return @slots.size | ||
| @slots.size | ||
| end | ||
@@ -42,6 +42,7 @@ | ||
| return if @thread | ||
| @thread = Thread.new do | ||
| loop do | ||
| begin | ||
| delay = get_delay | ||
| delay = calculate_delay | ||
| if delay.nil? | ||
@@ -54,6 +55,6 @@ Thread.stop | ||
| while get_first_expired do | ||
| while fetch_first_expired | ||
| end | ||
| rescue => e | ||
| puts e.inspect + "\n" + e.backtrace.join("\n") | ||
| rescue StandardError => e | ||
| puts "#{e.inspect}\n#{e.backtrace.join("\n")}" | ||
| end | ||
@@ -66,7 +67,7 @@ end | ||
| def get_delay | ||
| return @mutex.synchronize { @slots.empty? ? nil : @slots.first.expires_at - utc_microseconds } | ||
| def calculate_delay | ||
| @mutex.synchronize { @slots.empty? ? nil : @slots.first.expires_at - utc_microseconds } | ||
| end | ||
| def get_first_expired | ||
| def fetch_first_expired | ||
| @mutex.synchronize do | ||
@@ -97,5 +98,5 @@ slot = @slots.first | ||
| def utc_microseconds | ||
| return Time.now.to_f * 1_000_000 | ||
| Time.now.to_f * 1_000_000 | ||
| end | ||
| end | ||
| end |
+14
-0
@@ -61,2 +61,16 @@ # Ztimer | ||
| | Method | Description | | ||
| |--------|-------------| | ||
| | `async(&block)` | Execute the block asynchronously. | | ||
| | `after(milliseconds, &block)` | Execute the block after the specified amount of milliseconds. | | ||
| | `at(datetime, &block)` | Execute the block at the specified timestamp. | | ||
| | `every(milliseconds, start_at: nil, &block)` | Execute the block at the specified interval of milliseconds. A custom `:start_at` param could be provided to specify an offset timestamp. | | ||
| | `secondly(seconds, offset: 0, &block)` | Executes the block every N seconds. An `:offset` of seconds could be specified to shift the begining of the time slot. By default the block will be exected at the begining of the time slot. Example: `secondly(5)` will run at second `0`, `5`, `10`, `15`, etc. | | ||
| | `minutely(minutes, offset: 0, &block)` | Executes the block every N minutes. An `:offset` of minutes could be specified to shift the begining of the time slot. By default the block will be exected at the begining of the time slot. Example: `minutely(5)` will run at minute `0`, `5`, `10`, `15`, etc. | | ||
| | `hourly(hours, offset: 0, &block)` | Executes the block every N hours. An `:offset` of hours could be specified to shift the begining of the time slot. By default the block will be exected at the begining of the time slot. Example: `hourly(5)` will run at hour `0`, `5`, `10`, `15`, etc. | | ||
| | `daily(days, offset: 0, &block)` | Executes the block every N days. An `:offset` of days could be specified to shift the begining of the time slot. By default the block will be exected at the begining of the time slot. Example: `daily(5)` will run on day `0`, `5`, `10`, `15`, etc. | | ||
| | `day_of_week(day, &block)` | Execute the block only on the specified day of week. Valid days are: `"sun", "mon", "tue", "thu", "wen", "fri", "sat"`. | | ||
| | `days_of_week(days, &block)` | Execute the block on the specified days of week. | | ||
| By default **Ztimer** will run at maximum 20 jobs concurrently, so that if you have 100 jobs to be | ||
@@ -63,0 +77,0 @@ executed at the same time, at most 20 of them will run concurrently. This is necessary in order to prevent uncontrolled threads spawn when many jobs have to be run at the same time. |
+17
-14
@@ -1,3 +0,4 @@ | ||
| # coding: utf-8 | ||
| lib = File.expand_path('../lib', __FILE__) | ||
| # frozen_string_literal: true | ||
| lib = File.expand_path('lib', __dir__) | ||
| $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) | ||
@@ -7,20 +8,22 @@ require 'ztimer/version' | ||
| Gem::Specification.new do |spec| | ||
| spec.name = "ztimer" | ||
| spec.name = 'ztimer' | ||
| spec.version = Ztimer::VERSION | ||
| spec.authors = ["Groza Sergiu"] | ||
| spec.email = ["serioja90@gmail.com"] | ||
| spec.authors = ['Groza Sergiu'] | ||
| spec.email = ['serioja90@gmail.com'] | ||
| spec.summary = %q{An asyncrhonous timer} | ||
| spec.description = %q{Ruby asyncrhonous timer that allows you to enqueue tasks to be executed asyncrhonously after a delay} | ||
| spec.homepage = "https://github.com/serioja90/ztimer" | ||
| spec.license = "MIT" | ||
| spec.summary = %(An asyncrhonous timer) | ||
| spec.description = %(Ruby asyncrhonous timer that allows you to enqueue tasks to be executed asyncrhonously after a delay) | ||
| spec.homepage = 'https://github.com/serioja90/ztimer' | ||
| spec.license = 'MIT' | ||
| spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } | ||
| spec.bindir = "exe" | ||
| spec.bindir = 'exe' | ||
| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } | ||
| spec.require_paths = ["lib"] | ||
| spec.require_paths = ['lib'] | ||
| spec.add_development_dependency "bundler", "~> 1.11" | ||
| spec.add_development_dependency "rake", "~> 10.0" | ||
| spec.add_development_dependency "rspec", "~> 3.0" | ||
| spec.required_ruby_version = '>= 2.5' | ||
| spec.add_development_dependency 'bundler', '~>2.2', '>= 2.2.33' | ||
| spec.add_development_dependency 'rake', '~>12.3', '>= 12.3.3' | ||
| spec.add_development_dependency 'rspec', '~> 3.0' | ||
| end |