schked 1.4.0 → 2.0.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 +4 -4
- data/README.md +116 -3
- data/lib/schked/adapters/active_record.rb +123 -0
- data/lib/schked/adapters/sequel.rb +108 -0
- data/lib/schked/callbacks.rb +111 -0
- data/lib/schked/cli.rb +22 -1
- data/lib/schked/config.rb +65 -1
- data/lib/schked/database_connection.rb +75 -0
- data/lib/schked/job_run_store.rb +30 -0
- data/lib/schked/liveness_probe.rb +190 -0
- data/lib/schked/migration_generator.rb +42 -0
- data/lib/schked/railtie.rb +10 -0
- data/lib/schked/redis_job_run_store.rb +68 -0
- data/lib/schked/schedule_dsl.rb +83 -0
- data/lib/schked/version.rb +1 -1
- data/lib/schked/worker.rb +84 -38
- data/lib/schked.rb +9 -0
- metadata +27 -4
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schked
|
|
4
|
+
# Auto-detects which +Schked::Adapters::*+ class to use for the
|
|
5
|
+
# database-backed coordination store. Returns a concrete +JobRunStore+
|
|
6
|
+
# instance — the caller should not need to know which backend is in play.
|
|
7
|
+
#
|
|
8
|
+
# Resolution order:
|
|
9
|
+
# 1. An explicit connection/pool if provided.
|
|
10
|
+
# 2. +ActiveRecord::Base.connection_pool+ when ActiveRecord is loaded
|
|
11
|
+
# and configured (the pool, not a single connection, so the adapter
|
|
12
|
+
# can check out per operation).
|
|
13
|
+
# 3. Sequel's first available +Sequel::DATABASES+ database.
|
|
14
|
+
# 4. Otherwise raises a clear +NotFoundError+ telling the operator to
|
|
15
|
+
# set +database_connection+ explicitly.
|
|
16
|
+
module DatabaseConnection
|
|
17
|
+
class NotFoundError < StandardError; end
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def detect(connection: nil, logger: Logger.new($stdout))
|
|
22
|
+
raw = connection || auto_detect_connection
|
|
23
|
+
wrap(raw, logger: logger)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def wrap(raw, logger: Logger.new($stdout))
|
|
27
|
+
case raw
|
|
28
|
+
when Adapters::Sequel, Adapters::ActiveRecord
|
|
29
|
+
raw
|
|
30
|
+
else
|
|
31
|
+
build_for(raw, logger: logger)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_for(raw, logger: Logger.new($stdout))
|
|
36
|
+
if defined?(::Sequel::Database) && raw.is_a?(::Sequel::Database)
|
|
37
|
+
Adapters::Sequel.new(raw, logger: logger)
|
|
38
|
+
elsif raw.respond_to?(:with_connection)
|
|
39
|
+
# An ActiveRecord connection pool.
|
|
40
|
+
Adapters::ActiveRecord.new(raw, logger: logger)
|
|
41
|
+
elsif raw.respond_to?(:pool) && raw.respond_to?(:adapter_name)
|
|
42
|
+
# A concrete ActiveRecord adapter connection — normalize to its pool.
|
|
43
|
+
Adapters::ActiveRecord.new(raw.pool, logger: logger)
|
|
44
|
+
elsif raw.respond_to?(:claim) && raw.respond_to?(:cleanup)
|
|
45
|
+
# Already a store: a pre-built adapter or a custom object.
|
|
46
|
+
raw
|
|
47
|
+
else
|
|
48
|
+
raise NotFoundError,
|
|
49
|
+
"Schked could not detect an adapter for: #{raw.class}. " \
|
|
50
|
+
"Provide a Sequel::Database, an ActiveRecord connection pool, " \
|
|
51
|
+
"or an ActiveRecord connection (PostgreSQL, Mysql2, or Trilogy)."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def auto_detect_connection
|
|
56
|
+
if defined?(ActiveRecord) && ActiveRecord.const_defined?(:Base)
|
|
57
|
+
begin
|
|
58
|
+
return ActiveRecord::Base.connection_pool
|
|
59
|
+
rescue
|
|
60
|
+
# ActiveRecord is loaded but not configured (or the pool cannot
|
|
61
|
+
# be resolved) — fall through to Sequel detection below.
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if defined?(Sequel) && Sequel.respond_to?(:DATABASES) && Sequel::DATABASES.any?
|
|
66
|
+
return Sequel::DATABASES.first
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
raise NotFoundError,
|
|
70
|
+
"Schked could not detect a database connection for the database-backed " \
|
|
71
|
+
"job run store. Load ActiveRecord or Sequel, or set `Schked.config.database_connection` " \
|
|
72
|
+
"explicitly."
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schked
|
|
4
|
+
# Abstract interface for the per-job coordination store used by the
|
|
5
|
+
# deduplication mode. A store records which `(job_name, window_start)` pairs
|
|
6
|
+
# have been claimed, so two scheduler instances cannot execute the same job
|
|
7
|
+
# in the same interval.
|
|
8
|
+
#
|
|
9
|
+
# Two built-in backends are provided: Redis (see +RedisJobRunStore+) and
|
|
10
|
+
# database (see +DatabaseJobRunStore+). An operator may also supply any
|
|
11
|
+
# custom object that responds to +#claim+ and +#cleanup+.
|
|
12
|
+
module JobRunStore
|
|
13
|
+
# Atomically records that +job_name+ was claimed for the interval starting
|
|
14
|
+
# at +window_start+ (a Time/Integer epoch).
|
|
15
|
+
#
|
|
16
|
+
# Returns +true+ if this caller won the claim (the job may run) and +false+
|
|
17
|
+
# if the interval is already claimed by another instance (the job must be
|
|
18
|
+
# skipped). Raises +ArgumentError+ for invalid arguments.
|
|
19
|
+
def claim(job_name, window_start)
|
|
20
|
+
raise NotImplementedError, "#{self.class} must implement #claim(job_name, window_start)"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Removes records whose window_start is older than +older_than+. The
|
|
24
|
+
# Redis backend relies on native TTL and provides a no-op; the database
|
|
25
|
+
# backend deletes matching rows.
|
|
26
|
+
def cleanup(older_than)
|
|
27
|
+
raise NotImplementedError, "#{self.class} must implement #cleanup(older_than)"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
|
|
6
|
+
module Schked
|
|
7
|
+
class LivenessProbeConfig
|
|
8
|
+
attr_reader :enabled, :bind, :port, :path, :heartbeat_interval, :heartbeat_threshold
|
|
9
|
+
|
|
10
|
+
DEFAULTS = {
|
|
11
|
+
enabled: false,
|
|
12
|
+
bind: "0.0.0.0",
|
|
13
|
+
port: 8080,
|
|
14
|
+
path: "/healthz",
|
|
15
|
+
heartbeat_interval: 5,
|
|
16
|
+
heartbeat_threshold: 15
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(attrs = {})
|
|
20
|
+
attrs = DEFAULTS.merge(attrs)
|
|
21
|
+
|
|
22
|
+
@enabled = !!attrs[:enabled]
|
|
23
|
+
@bind = validate_bind(attrs[:bind])
|
|
24
|
+
@port = validate_port(attrs[:port])
|
|
25
|
+
@path = validate_path(attrs[:path])
|
|
26
|
+
@heartbeat_interval = validate_positive_integer(attrs[:heartbeat_interval], :heartbeat_interval)
|
|
27
|
+
@heartbeat_threshold = validate_threshold(attrs[:heartbeat_threshold], attrs[:heartbeat_interval])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{
|
|
32
|
+
enabled: enabled,
|
|
33
|
+
bind: bind,
|
|
34
|
+
port: port,
|
|
35
|
+
path: path,
|
|
36
|
+
heartbeat_interval: heartbeat_interval,
|
|
37
|
+
heartbeat_threshold: heartbeat_threshold
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_bind(value)
|
|
44
|
+
value = value.to_s
|
|
45
|
+
raise ArgumentError, "Schked liveness_probe `bind` must be non-empty" if value.empty?
|
|
46
|
+
|
|
47
|
+
IPAddr.new(value)
|
|
48
|
+
value
|
|
49
|
+
rescue IPAddr::InvalidAddressError
|
|
50
|
+
raise ArgumentError, "Schked liveness_probe `bind` is invalid: #{value}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_port(value)
|
|
54
|
+
port = Integer(value)
|
|
55
|
+
raise ArgumentError, "Schked liveness_probe `port` must be between 1 and 65535, got: #{port}" unless port.between?(1, 65_535)
|
|
56
|
+
|
|
57
|
+
port
|
|
58
|
+
rescue ArgumentError, TypeError
|
|
59
|
+
raise ArgumentError, "Schked liveness_probe `port` must be an integer between 1 and 65535, got: #{value.inspect}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_path(value)
|
|
63
|
+
value = value.to_s
|
|
64
|
+
raise ArgumentError, "Schked liveness_probe `path` must start with /, got: #{value}" unless value.start_with?("/")
|
|
65
|
+
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_positive_integer(value, name)
|
|
70
|
+
int = Integer(value)
|
|
71
|
+
raise ArgumentError, "Schked liveness_probe `#{name}` must be a positive integer, got: #{int}" unless int.positive?
|
|
72
|
+
|
|
73
|
+
int
|
|
74
|
+
rescue ArgumentError, TypeError
|
|
75
|
+
raise ArgumentError, "Schked liveness_probe `#{name}` must be a positive integer, got: #{value.inspect}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_threshold(value, interval)
|
|
79
|
+
int = validate_positive_integer(value, :heartbeat_threshold)
|
|
80
|
+
interval = validate_positive_integer(interval, :heartbeat_interval)
|
|
81
|
+
raise ArgumentError, "Schked liveness_probe `heartbeat_threshold` must be >= heartbeat_interval" if int < interval
|
|
82
|
+
|
|
83
|
+
int
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class LivenessProbe
|
|
88
|
+
attr_reader :config, :logger
|
|
89
|
+
|
|
90
|
+
def initialize(config:, logger:)
|
|
91
|
+
@config = config
|
|
92
|
+
@logger = logger
|
|
93
|
+
@last_heartbeat_at = nil
|
|
94
|
+
@shutting_down = false
|
|
95
|
+
@server = nil
|
|
96
|
+
@thread = nil
|
|
97
|
+
@mutex = Mutex.new
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def start
|
|
101
|
+
return unless config.enabled
|
|
102
|
+
|
|
103
|
+
@server = create_server
|
|
104
|
+
logger.info("Schked liveness probe listening on #{config.bind}:#{config.port}#{config.path}")
|
|
105
|
+
|
|
106
|
+
@thread = Thread.new {
|
|
107
|
+
Thread.current.name = "schked-liveness-probe" if Thread.current.respond_to?(:name=)
|
|
108
|
+
accept_loop
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def heartbeat
|
|
113
|
+
@last_heartbeat_at = Time.now
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def stop
|
|
117
|
+
@mutex.synchronize do
|
|
118
|
+
return if @shutting_down
|
|
119
|
+
|
|
120
|
+
@shutting_down = true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
sleep 0.1
|
|
124
|
+
@server&.close
|
|
125
|
+
@thread&.join(5)
|
|
126
|
+
logger.info("Schked liveness probe stopped")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def healthy?
|
|
130
|
+
return false if @shutting_down
|
|
131
|
+
return false if @last_heartbeat_at.nil?
|
|
132
|
+
|
|
133
|
+
Time.now - @last_heartbeat_at <= config.heartbeat_threshold
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def create_server
|
|
139
|
+
TCPServer.new(config.bind, config.port)
|
|
140
|
+
rescue Errno::EADDRINUSE => e
|
|
141
|
+
raise ArgumentError, "Schked liveness probe port #{config.port} is already in use on #{config.bind}: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def accept_loop
|
|
145
|
+
loop do
|
|
146
|
+
client = @server.accept
|
|
147
|
+
|
|
148
|
+
Thread.new(client) do |conn|
|
|
149
|
+
handle_client(conn)
|
|
150
|
+
end
|
|
151
|
+
rescue IOError, Errno::EBADF
|
|
152
|
+
break
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handle_client(client)
|
|
157
|
+
request_line = read_request_line(client)
|
|
158
|
+
return if request_line.nil?
|
|
159
|
+
|
|
160
|
+
_method, path, _protocol = request_line.split(" ", 3)
|
|
161
|
+
|
|
162
|
+
response = response_for(path)
|
|
163
|
+
client.print(response)
|
|
164
|
+
ensure
|
|
165
|
+
begin
|
|
166
|
+
client.close
|
|
167
|
+
rescue IOError
|
|
168
|
+
# already closed
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def read_request_line(client)
|
|
173
|
+
return nil unless IO.select([client], nil, nil, 5)
|
|
174
|
+
|
|
175
|
+
client.gets
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def response_for(path)
|
|
179
|
+
if path == config.path
|
|
180
|
+
if healthy?
|
|
181
|
+
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"
|
|
182
|
+
else
|
|
183
|
+
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nContent-Length: 11\r\nConnection: close\r\n\r\nUnavailable"
|
|
184
|
+
end
|
|
185
|
+
else
|
|
186
|
+
"HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schked
|
|
4
|
+
# Generates the DDL for the +schked_job_runs+ table used by the
|
|
5
|
+
# database-backed coordination store. Operators copy the SQL into their own
|
|
6
|
+
# migration; schked does not write migration files or run DDL on its own.
|
|
7
|
+
module MigrationGenerator
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
DDL_POSTGRES = <<~SQL
|
|
11
|
+
CREATE TABLE schked_job_runs (
|
|
12
|
+
id BIGSERIAL PRIMARY KEY,
|
|
13
|
+
job_name TEXT NOT NULL,
|
|
14
|
+
window_start BIGINT NOT NULL,
|
|
15
|
+
run_at DOUBLE PRECISION NOT NULL,
|
|
16
|
+
claimer TEXT NOT NULL,
|
|
17
|
+
CONSTRAINT schked_job_runs_unique UNIQUE (job_name, window_start)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX schked_job_runs_window_start_idx ON schked_job_runs (window_start);
|
|
21
|
+
SQL
|
|
22
|
+
|
|
23
|
+
DDL_MYSQL = <<~SQL
|
|
24
|
+
CREATE TABLE schked_job_runs (
|
|
25
|
+
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
|
26
|
+
job_name VARCHAR(255) NOT NULL,
|
|
27
|
+
window_start BIGINT NOT NULL,
|
|
28
|
+
run_at DOUBLE NOT NULL,
|
|
29
|
+
claimer VARCHAR(255) NOT NULL,
|
|
30
|
+
UNIQUE KEY schked_job_runs_unique (job_name, window_start),
|
|
31
|
+
KEY schked_job_runs_window_start_idx (window_start)
|
|
32
|
+
);
|
|
33
|
+
SQL
|
|
34
|
+
|
|
35
|
+
def sql(flavor = "postgres")
|
|
36
|
+
case flavor.to_s.downcase
|
|
37
|
+
when "mysql" then DDL_MYSQL
|
|
38
|
+
else DDL_POSTGRES
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/schked/railtie.rb
CHANGED
|
@@ -4,6 +4,8 @@ require "rails/railtie"
|
|
|
4
4
|
|
|
5
5
|
module Schked
|
|
6
6
|
class Railtie < Rails::Railtie
|
|
7
|
+
config.schked = ActiveSupport::OrderedOptions.new
|
|
8
|
+
|
|
7
9
|
class PathsConfig
|
|
8
10
|
def self.call(app)
|
|
9
11
|
return if Schked.config.do_not_load_root_schedule?
|
|
@@ -16,7 +18,15 @@ module Schked
|
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
class LivenessConfig
|
|
22
|
+
def self.call(app)
|
|
23
|
+
liveness_probe = app.config.schked.liveness_probe
|
|
24
|
+
Schked.config.liveness_probe = liveness_probe if liveness_probe
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
initializer("schked.paths", &PathsConfig.method(:call))
|
|
29
|
+
initializer("schked.liveness", after: :load_config_initializers) { |app| LivenessConfig.call(app) }
|
|
20
30
|
|
|
21
31
|
config.to_prepare do
|
|
22
32
|
Schked.config.logger = ::Rails.logger unless Schked.config.logger?
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Schked
|
|
4
|
+
# Redis-backed implementation of the per-job coordination store.
|
|
5
|
+
# Uses +SET key 1 NX EX <ttl>+ so the first caller wins and TTL handles
|
|
6
|
+
# expiration; #cleanup is a no-op because native TTL covers retention.
|
|
7
|
+
class RedisJobRunStore
|
|
8
|
+
include JobRunStore
|
|
9
|
+
|
|
10
|
+
KEY_PREFIX = "schked:job_run"
|
|
11
|
+
|
|
12
|
+
attr_reader :redis_client, :logger, :max_skew_seconds
|
|
13
|
+
|
|
14
|
+
def initialize(redis_client:, logger: Logger.new($stdout), max_skew_seconds: 60)
|
|
15
|
+
@redis_client = redis_client
|
|
16
|
+
@logger = logger
|
|
17
|
+
@max_skew_seconds = Integer(max_skew_seconds)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def claim(job_name, window_start)
|
|
21
|
+
validate!(job_name, window_start)
|
|
22
|
+
|
|
23
|
+
key = build_key(job_name, window_start)
|
|
24
|
+
ttl = default_ttl
|
|
25
|
+
|
|
26
|
+
# +SET ... NX EX+ is atomic on a single Redis instance and is the
|
|
27
|
+
# idiomatic primitive for "claim this slot for at most N seconds".
|
|
28
|
+
# Transport errors (connection refused, timeout, ...) raise out of
|
|
29
|
+
# this method so the caller knows the store is unavailable — silently
|
|
30
|
+
# returning +false+ would skip every job while Redis is down.
|
|
31
|
+
#
|
|
32
|
+
# Why not Redlock? Redlock (the algorithm used by +RedisLocker+) is
|
|
33
|
+
# designed for cluster-wide consensus across multiple Redis masters.
|
|
34
|
+
# For this dedup, a single +SET NX EX+ is already atomic per
|
|
35
|
+
# instance and sufficient for exactly-once across the cluster of
|
|
36
|
+
# *schedulers* — the scheduler cluster itself uses one Redis (or a
|
|
37
|
+
# single master with replicas).
|
|
38
|
+
redis_client.call("SET", key, "1", "NX", "EX", ttl) == "OK"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cleanup(_older_than)
|
|
42
|
+
# Native Redis TTL handles expiration; nothing to do here.
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def default_ttl
|
|
49
|
+
# The key only needs to outlive the contention window — the interval
|
|
50
|
+
# (up to +max_skew+, plus scheduling jitter) during which instances
|
|
51
|
+
# race to claim the same slot. Expiry between windows is harmless:
|
|
52
|
+
# each window gets its own key. The floor keeps a minimum protective
|
|
53
|
+
# period for one-shot +at+/+in+ claims against instances that boot
|
|
54
|
+
# with a delay.
|
|
55
|
+
[10 * @max_skew_seconds, 3600].max
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_key(job_name, window_start)
|
|
59
|
+
ts = window_start.is_a?(Time) ? window_start.to_i : Integer(window_start)
|
|
60
|
+
"#{KEY_PREFIX}:#{job_name}:#{ts}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate!(job_name, window_start)
|
|
64
|
+
raise ArgumentError, "job_name must be a non-empty String" if job_name.to_s.empty?
|
|
65
|
+
raise ArgumentError, "window_start must not be nil" if window_start.nil?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rufus/scheduler"
|
|
4
|
+
|
|
5
|
+
module Schked
|
|
6
|
+
# Wraps the rufus-scheduler DSL so deduplication mode (+job_run_store+ is
|
|
7
|
+
# configured) makes +every+ jobs grid-aligned (via injected +first_at+) and
|
|
8
|
+
# rejects +interval+ jobs (their phase drifts with job duration and cannot
|
|
9
|
+
# be deduplicated).
|
|
10
|
+
#
|
|
11
|
+
# In the default (non-dedup) mode the wrapper is transparent and forwards
|
|
12
|
+
# every call to the underlying scheduler.
|
|
13
|
+
class ScheduleDSL
|
|
14
|
+
class IntervalNotSupportedError < StandardError; end
|
|
15
|
+
|
|
16
|
+
attr_reader :scheduler
|
|
17
|
+
|
|
18
|
+
def initialize(scheduler:, dedup_enabled:, max_skew_seconds: 60, logger: Logger.new(File::NULL))
|
|
19
|
+
@scheduler = scheduler
|
|
20
|
+
@dedup_enabled = dedup_enabled
|
|
21
|
+
@max_skew_seconds = Integer(max_skew_seconds)
|
|
22
|
+
@logger = logger
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def respond_to_missing?(name, include_private = false)
|
|
26
|
+
@scheduler.respond_to?(name, include_private) || super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
30
|
+
case name
|
|
31
|
+
when :every
|
|
32
|
+
every_with_alignment(*args, **kwargs, &block)
|
|
33
|
+
when :interval
|
|
34
|
+
raise IntervalNotSupportedError, interval_error_message if @dedup_enabled
|
|
35
|
+
|
|
36
|
+
@scheduler.interval(*args, **kwargs, &block)
|
|
37
|
+
else
|
|
38
|
+
@scheduler.public_send(name, *args, **kwargs, &block)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def every_with_alignment(duration, *args, **kwargs, &block)
|
|
45
|
+
if @dedup_enabled
|
|
46
|
+
seconds = Rufus::Scheduler.parse_duration(duration)
|
|
47
|
+
# Shift "now" backward by +max_skew+ so that two instances whose
|
|
48
|
+
# clocks differ by up to +max_skew+ still land on the same grid
|
|
49
|
+
# point. Without this shift, an instance that is half a skew
|
|
50
|
+
# ahead could pick a different slot than one that is half a skew
|
|
51
|
+
# behind, and both would claim the job.
|
|
52
|
+
first_at = Time.at(next_grid_point_epoch(seconds, Time.now.to_f - @max_skew_seconds))
|
|
53
|
+
if kwargs.key?(:first_at)
|
|
54
|
+
@logger.warn(
|
|
55
|
+
"Schked: ignoring `first_at: #{kwargs[:first_at].inspect}` for `every` job " \
|
|
56
|
+
"in deduplication mode — grid alignment is required for claims"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
kwargs = kwargs.merge(first_at: first_at)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@scheduler.every(duration, *args, **kwargs, &block)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def next_grid_point_epoch(seconds, shifted_now)
|
|
66
|
+
grid_point = (shifted_now / seconds).ceil * seconds
|
|
67
|
+
# When the interval is comparable to or smaller than +max_skew+, the
|
|
68
|
+
# grid point closest to "now − max_skew" may still lie in the past,
|
|
69
|
+
# and rufus-scheduler rejects a past +first_at+. Advance by whole
|
|
70
|
+
# periods until the slot is strictly in the future: every step keeps
|
|
71
|
+
# the value on the absolute (epoch-multiple) grid, so all instances
|
|
72
|
+
# stay phase-aligned regardless of how many steps they take.
|
|
73
|
+
grid_point += seconds while grid_point <= Time.now.to_f
|
|
74
|
+
grid_point
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def interval_error_message
|
|
78
|
+
"`interval` jobs are not supported when `Schked.config.job_run_store` is configured. " \
|
|
79
|
+
"Their phase depends on job duration and cannot be aligned to a stable grid. " \
|
|
80
|
+
"Use `every` or `cron` instead."
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/schked/version.rb
CHANGED
data/lib/schked/worker.rb
CHANGED
|
@@ -4,17 +4,26 @@ require "rufus/scheduler"
|
|
|
4
4
|
|
|
5
5
|
module Schked
|
|
6
6
|
class Worker
|
|
7
|
+
DEFAULT_CLEANUP_INTERVAL = "60s"
|
|
8
|
+
DEFAULT_CLEANUP_RETENTION = 24 * 60 * 60 # seconds before cutoff
|
|
9
|
+
|
|
7
10
|
def initialize(config:)
|
|
8
11
|
@config = config
|
|
12
|
+
@liveness_probe = nil
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
config.validate!
|
|
15
|
+
@job_run_store = build_job_run_store
|
|
16
|
+
@locker = build_locker
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
scheduler_opts = {trigger_lock: locker}.compact
|
|
19
|
+
@scheduler = Rufus::Scheduler.new(**scheduler_opts)
|
|
13
20
|
|
|
14
21
|
watch_signals
|
|
15
|
-
|
|
16
|
-
define_extend_lock
|
|
22
|
+
Callbacks.new(config: config, job_run_store: @job_run_store).install(@scheduler)
|
|
23
|
+
define_extend_lock if locker
|
|
24
|
+
define_cleanup_job if database_backed_store?
|
|
17
25
|
load_schedule
|
|
26
|
+
start_liveness_probe
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
def job(as)
|
|
@@ -30,6 +39,7 @@ module Schked
|
|
|
30
39
|
end
|
|
31
40
|
|
|
32
41
|
def stop
|
|
42
|
+
liveness_probe&.stop
|
|
33
43
|
scheduler.stop
|
|
34
44
|
end
|
|
35
45
|
|
|
@@ -44,41 +54,33 @@ module Schked
|
|
|
44
54
|
|
|
45
55
|
private
|
|
46
56
|
|
|
47
|
-
attr_reader :config, :scheduler, :locker
|
|
48
|
-
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
scheduler.define_singleton_method(:on_pre_trigger) do |job, time|
|
|
68
|
-
cfg.logger.info("Started task: #{extract_job_name(job)}")
|
|
69
|
-
|
|
70
|
-
cfg.fire_callback(:before_start, job, time)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
scheduler.define_singleton_method(:around_trigger) do |job, &block|
|
|
74
|
-
cfg.fire_around_callback(:around_job, job, &block)
|
|
57
|
+
attr_reader :config, :scheduler, :locker, :liveness_probe, :job_run_store
|
|
58
|
+
|
|
59
|
+
def build_job_run_store
|
|
60
|
+
return nil unless config.dedup_enabled?
|
|
61
|
+
|
|
62
|
+
case config.job_run_store
|
|
63
|
+
when :redis
|
|
64
|
+
RedisJobRunStore.new(
|
|
65
|
+
redis_client: RedisClientFactory.build(config.redis),
|
|
66
|
+
logger: config.logger,
|
|
67
|
+
max_skew_seconds: config.max_skew
|
|
68
|
+
)
|
|
69
|
+
when :database
|
|
70
|
+
DatabaseConnection.detect(
|
|
71
|
+
connection: config.database_connection,
|
|
72
|
+
logger: config.logger
|
|
73
|
+
)
|
|
74
|
+
else
|
|
75
|
+
config.job_run_store
|
|
75
76
|
end
|
|
77
|
+
end
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
def build_locker
|
|
80
|
+
return nil if config.standalone?
|
|
81
|
+
return nil if config.dedup_enabled?
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
end
|
|
83
|
+
RedisLocker.new(config.redis, lock_ttl: 40_000, logger: config.logger)
|
|
82
84
|
end
|
|
83
85
|
|
|
84
86
|
def watch_signals
|
|
@@ -94,7 +96,10 @@ module Schked
|
|
|
94
96
|
|
|
95
97
|
Thread.new do
|
|
96
98
|
loop do
|
|
97
|
-
|
|
99
|
+
if @shutdown
|
|
100
|
+
liveness_probe&.stop
|
|
101
|
+
scheduler.shutdown(wait: 5)
|
|
102
|
+
end
|
|
98
103
|
sleep 1
|
|
99
104
|
end
|
|
100
105
|
end
|
|
@@ -106,8 +111,49 @@ module Schked
|
|
|
106
111
|
end
|
|
107
112
|
end
|
|
108
113
|
|
|
114
|
+
def define_cleanup_job
|
|
115
|
+
store = @job_run_store
|
|
116
|
+
logger = config.logger
|
|
117
|
+
|
|
118
|
+
scheduler.every(DEFAULT_CLEANUP_INTERVAL, as: "Schked::Worker#cleanup_job_runs", overlap: false) do
|
|
119
|
+
cutoff = Time.now.to_i - (DEFAULT_CLEANUP_RETENTION + config.max_skew)
|
|
120
|
+
logger.debug("Cleaning up database job runs older than #{cutoff}")
|
|
121
|
+
store.cleanup(cutoff)
|
|
122
|
+
rescue => e
|
|
123
|
+
logger.error("Failed to clean up database job runs: #{e.message}")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Only stores whose +#cleanup+ actually deletes rows need the sweep.
|
|
128
|
+
# +RedisJobRunStore#cleanup+ is a no-op (native TTL handles retention),
|
|
129
|
+
# so scheduling it would just log noise on every instance. Custom
|
|
130
|
+
# stores keep the sweep because they implemented +#cleanup+ for it.
|
|
131
|
+
# Returns +false+ when no store is configured (default mode).
|
|
132
|
+
def database_backed_store?
|
|
133
|
+
!job_run_store.nil? && !job_run_store.is_a?(RedisJobRunStore)
|
|
134
|
+
end
|
|
135
|
+
|
|
109
136
|
def load_schedule
|
|
110
|
-
|
|
137
|
+
dsl = ScheduleDSL.new(
|
|
138
|
+
scheduler: scheduler,
|
|
139
|
+
dedup_enabled: config.dedup_enabled?,
|
|
140
|
+
max_skew_seconds: config.max_skew,
|
|
141
|
+
logger: config.logger
|
|
142
|
+
)
|
|
143
|
+
dsl.instance_eval(schedule)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def start_liveness_probe
|
|
147
|
+
return unless config.liveness_probe.enabled
|
|
148
|
+
|
|
149
|
+
@liveness_probe = LivenessProbe.new(config: config.liveness_probe, logger: config.logger)
|
|
150
|
+
@liveness_probe.start
|
|
151
|
+
|
|
152
|
+
scheduler.every("#{config.liveness_probe.heartbeat_interval}s", as: "Schked::Worker#liveness_heartbeat", overlap: false) do
|
|
153
|
+
@liveness_probe.heartbeat
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
@liveness_probe.heartbeat
|
|
111
157
|
end
|
|
112
158
|
end
|
|
113
159
|
end
|