async-background 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/async/background/entry.rb +33 -0
- data/lib/async/background/min_heap.rb +69 -0
- data/lib/async/background/runner.rb +144 -0
- data/lib/async/background/version.rb +7 -0
- data/lib/async/background.rb +10 -0
- metadata +126 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 81377ce73dc827a65ac5710dcc5343a2c2d97d80800489f50c8c334a71cc5f76
|
|
4
|
+
data.tar.gz: 34f372d35ec9b98b88c3009268c2b4427e8d4211c6534d2f0ec9099be922ac5c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cdda5f36c0dcbcabc97b3d580af897ea466c382358848faa436b04d0bfe61e18e16e1743246848f57546ba4908c3876f83f314983b97caf419381757cb1849c7
|
|
7
|
+
data.tar.gz: 227eb8648f9a611e866766246b63c288a1d15dc2e7447c8323f11c1d1c7bae5ceebab215ec7922901cb1bb66c4c701bf4d2fcf1dadfcd18b1def693559490d2c
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
class Entry
|
|
6
|
+
MIN_SLEEP_TIME = 0.1
|
|
7
|
+
|
|
8
|
+
attr_reader :name, :job_class, :interval, :cron, :timeout
|
|
9
|
+
attr_accessor :next_run_at, :running
|
|
10
|
+
|
|
11
|
+
def initialize(name:, job_class:, interval:, cron:, timeout:, next_run_at:)
|
|
12
|
+
@name = name
|
|
13
|
+
@job_class = job_class
|
|
14
|
+
@interval = interval
|
|
15
|
+
@cron = cron
|
|
16
|
+
@timeout = timeout
|
|
17
|
+
@next_run_at = next_run_at
|
|
18
|
+
@running = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reschedule(monotonic_now)
|
|
22
|
+
if interval
|
|
23
|
+
@next_run_at += interval
|
|
24
|
+
@next_run_at = monotonic_now + interval if @next_run_at <= monotonic_now
|
|
25
|
+
else
|
|
26
|
+
now_wall = Time.now
|
|
27
|
+
wait = cron.next_time(now_wall).to_f - now_wall.to_f
|
|
28
|
+
@next_run_at = monotonic_now + [wait, MIN_SLEEP_TIME].max
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Async
|
|
4
|
+
module Background
|
|
5
|
+
class MinHeap
|
|
6
|
+
def initialize
|
|
7
|
+
@data = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def push(entry)
|
|
11
|
+
@data << entry
|
|
12
|
+
sift_up(@data.size - 1)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def pop
|
|
16
|
+
return if @data.empty?
|
|
17
|
+
|
|
18
|
+
swap(0, @data.size - 1)
|
|
19
|
+
entry = @data.pop
|
|
20
|
+
sift_down(0) unless @data.empty?
|
|
21
|
+
entry
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def peek
|
|
25
|
+
@data.first
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def empty?
|
|
29
|
+
@data.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def size
|
|
33
|
+
@data.size
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def sift_up(i)
|
|
39
|
+
while i > 0
|
|
40
|
+
parent = (i - 1) >> 1
|
|
41
|
+
break if @data[parent].next_run_at <= @data[i].next_run_at
|
|
42
|
+
|
|
43
|
+
swap(i, parent)
|
|
44
|
+
i = parent
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sift_down(i)
|
|
49
|
+
while true
|
|
50
|
+
smallest = i
|
|
51
|
+
left = (i << 1) + 1
|
|
52
|
+
right = left + 1
|
|
53
|
+
|
|
54
|
+
smallest = left if left < @data.size && @data[left].next_run_at < @data[smallest].next_run_at
|
|
55
|
+
smallest = right if right < @data.size && @data[right].next_run_at < @data[smallest].next_run_at
|
|
56
|
+
|
|
57
|
+
break if smallest == i
|
|
58
|
+
|
|
59
|
+
swap(i, smallest)
|
|
60
|
+
i = smallest
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def swap(a, b)
|
|
65
|
+
@data[a], @data[b] = @data[b], @data[a]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
|
|
6
|
+
module Async
|
|
7
|
+
module Background
|
|
8
|
+
class ConfigError < StandardError; end
|
|
9
|
+
|
|
10
|
+
DEFAULT_TIMEOUT = 30
|
|
11
|
+
MIN_SLEEP_TIME = 0.1
|
|
12
|
+
MAX_JITTER = 5
|
|
13
|
+
|
|
14
|
+
class Runner
|
|
15
|
+
attr_reader :logger, :semaphore, :heap, :worker_index, :total_workers
|
|
16
|
+
|
|
17
|
+
def initialize(config_path:, job_count: 2, worker_index:, total_workers:)
|
|
18
|
+
@logger = Console.logger
|
|
19
|
+
@worker_index = worker_index
|
|
20
|
+
@total_workers = total_workers
|
|
21
|
+
|
|
22
|
+
logger.info { "Async::Background worker_index=#{worker_index}/#{total_workers}, job_count=#{job_count}" }
|
|
23
|
+
|
|
24
|
+
@semaphore = ::Async::Semaphore.new(job_count)
|
|
25
|
+
@heap = build_heap(config_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
Async do |task|
|
|
30
|
+
while true
|
|
31
|
+
entry = heap.peek
|
|
32
|
+
break unless entry
|
|
33
|
+
|
|
34
|
+
now = monotonic_now
|
|
35
|
+
wait = [entry.next_run_at - now, MIN_SLEEP_TIME].max
|
|
36
|
+
task.sleep wait
|
|
37
|
+
|
|
38
|
+
now = monotonic_now
|
|
39
|
+
while (top = heap.peek) && top.next_run_at <= now
|
|
40
|
+
entry = heap.pop
|
|
41
|
+
|
|
42
|
+
if entry.running
|
|
43
|
+
logger.warn('Async::Background') { "#{entry.name}: skipped, previous run still active" }
|
|
44
|
+
else
|
|
45
|
+
entry.running = true
|
|
46
|
+
semaphore.async(parent: task) do
|
|
47
|
+
run_job(task, entry)
|
|
48
|
+
ensure
|
|
49
|
+
entry.running = false
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
entry.reschedule(monotonic_now)
|
|
54
|
+
heap.push(entry)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def build_heap(config_path)
|
|
63
|
+
raise ConfigError, "Schedule file not found: #{config_path}" unless File.exist?(config_path)
|
|
64
|
+
|
|
65
|
+
raw = YAML.safe_load_file(config_path, permitted_classes: [Symbol])
|
|
66
|
+
raise ConfigError, "Empty schedule: #{config_path}" unless raw&.any?
|
|
67
|
+
|
|
68
|
+
heap = MinHeap.new
|
|
69
|
+
now = monotonic_now
|
|
70
|
+
|
|
71
|
+
raw.each do |name, config|
|
|
72
|
+
assigned = config['worker']&.to_i || ((Zlib.crc32(name) % total_workers) + 1)
|
|
73
|
+
next unless assigned == worker_index
|
|
74
|
+
|
|
75
|
+
task_config = build_task_config(name, config)
|
|
76
|
+
jitter = rand * [task_config[:interval] || MAX_JITTER, MAX_JITTER].min
|
|
77
|
+
|
|
78
|
+
next_run_at = if task_config[:interval]
|
|
79
|
+
now + jitter + task_config[:interval]
|
|
80
|
+
else
|
|
81
|
+
now_wall = Time.now
|
|
82
|
+
wall_wait = task_config[:cron].next_time(now_wall).to_f - now_wall.to_f
|
|
83
|
+
now + jitter + [wall_wait, MIN_SLEEP_TIME].max
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
heap.push(Entry.new(
|
|
87
|
+
name: name,
|
|
88
|
+
job_class: task_config[:job_class],
|
|
89
|
+
interval: task_config[:interval],
|
|
90
|
+
cron: task_config[:cron],
|
|
91
|
+
timeout: task_config[:timeout],
|
|
92
|
+
next_run_at: next_run_at
|
|
93
|
+
))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
heap
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_task_config(name, config)
|
|
100
|
+
class_name = config&.dig('class').to_s.strip
|
|
101
|
+
raise ConfigError, "[#{name}] missing class" if class_name.empty?
|
|
102
|
+
|
|
103
|
+
job_class = class_name.safe_constantize
|
|
104
|
+
raise ConfigError, "[#{name}] unknown class: #{class_name}" unless job_class
|
|
105
|
+
raise ConfigError, "[#{name}] #{class_name} must implement #perform" unless job_class.method_defined?(:perform)
|
|
106
|
+
|
|
107
|
+
interval = config['every']&.then { |v|
|
|
108
|
+
int = v.to_i
|
|
109
|
+
raise ConfigError, "[#{name}] 'every' must be > 0" unless int.positive?
|
|
110
|
+
int
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
cron = config['cron']&.then { |c|
|
|
114
|
+
Fugit::Cron.new(c) || raise(ConfigError, "[#{name}] invalid cron: #{c}")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
raise ConfigError, "[#{name}] specify 'every' or 'cron'" unless interval || cron
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
job_class: job_class,
|
|
121
|
+
interval: interval,
|
|
122
|
+
cron: cron,
|
|
123
|
+
timeout: config.fetch('timeout', DEFAULT_TIMEOUT).to_i
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def monotonic_now
|
|
128
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def run_job(task, entry)
|
|
132
|
+
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
133
|
+
task.with_timeout(entry.timeout) { entry.job_class.perform_now }
|
|
134
|
+
logger.info('Async::Background') {
|
|
135
|
+
"#{entry.name}: completed in #{(Process.clock_gettime(Process::CLOCK_MONOTONIC) - t).round(2)}s"
|
|
136
|
+
}
|
|
137
|
+
rescue ::Async::TimeoutError
|
|
138
|
+
logger.error('Async::Background') { "#{entry.name}: timed out after #{entry.timeout}s" }
|
|
139
|
+
rescue => e
|
|
140
|
+
logger.error('Async::Background') { "#{entry.name}: #{e.class} #{e.message}" }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: async-background
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Roman Hajdarov
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-15 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: async
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: console
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: fugit
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rake
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '13.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '13.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rspec
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.12'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.12'
|
|
83
|
+
description: |
|
|
84
|
+
A production-grade lightweight scheduler built on top of Async.
|
|
85
|
+
Single event loop with min-heap timer, skip-overlapping execution,
|
|
86
|
+
jitter, monotonic clock intervals, semaphore concurrency control,
|
|
87
|
+
and deterministic worker sharding. Designed for Falcon but works
|
|
88
|
+
with any Async-based application.
|
|
89
|
+
email:
|
|
90
|
+
- romnhajdarov@gmail.com
|
|
91
|
+
executables: []
|
|
92
|
+
extensions: []
|
|
93
|
+
extra_rdoc_files: []
|
|
94
|
+
files:
|
|
95
|
+
- lib/async/background.rb
|
|
96
|
+
- lib/async/background/entry.rb
|
|
97
|
+
- lib/async/background/min_heap.rb
|
|
98
|
+
- lib/async/background/runner.rb
|
|
99
|
+
- lib/async/background/version.rb
|
|
100
|
+
homepage: https://github.com/roman-haidarov/async-background
|
|
101
|
+
licenses:
|
|
102
|
+
- MIT
|
|
103
|
+
metadata:
|
|
104
|
+
source_code_uri: https://github.com/roman-haidarov/async-background
|
|
105
|
+
changelog_uri: https://github.com/roman-haidarov/async-background/blob/main/CHANGELOG.md
|
|
106
|
+
bug_tracker_uri: https://github.com/roman-haidarov/async-background/issues
|
|
107
|
+
post_install_message:
|
|
108
|
+
rdoc_options: []
|
|
109
|
+
require_paths:
|
|
110
|
+
- lib
|
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '3.3'
|
|
116
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
117
|
+
requirements:
|
|
118
|
+
- - ">="
|
|
119
|
+
- !ruby/object:Gem::Version
|
|
120
|
+
version: '0'
|
|
121
|
+
requirements: []
|
|
122
|
+
rubygems_version: 3.4.22
|
|
123
|
+
signing_key:
|
|
124
|
+
specification_version: 4
|
|
125
|
+
summary: Lightweight heap-based cron/interval scheduler for Async.
|
|
126
|
+
test_files: []
|