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.
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Schked
4
- VERSION = "1.4.0"
4
+ VERSION = "2.0.0"
5
5
  end
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
- @locker = RedisLocker.new(config.redis, lock_ttl: 40_000, logger: config.logger) unless config.standalone?
14
+ config.validate!
15
+ @job_run_store = build_job_run_store
16
+ @locker = build_locker
11
17
 
12
- @scheduler = Rufus::Scheduler.new(trigger_lock: locker)
18
+ scheduler_opts = {trigger_lock: locker}.compact
19
+ @scheduler = Rufus::Scheduler.new(**scheduler_opts)
13
20
 
14
21
  watch_signals
15
- define_callbacks
16
- define_extend_lock unless config.standalone?
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 define_callbacks
50
- cfg = config
51
-
52
- scheduler.define_singleton_method(:extract_job_name) do |job|
53
- if job
54
- job.opts[:as] || job.job_id
55
- else
56
- "unknown"
57
- end
58
- end
59
-
60
- scheduler.define_singleton_method(:on_error) do |job, error|
61
- cfg.logger.fatal("Task #{extract_job_name(job)} failed with error: #{error.message}")
62
- cfg.logger.error(error.backtrace.join("\n")) if error.backtrace
63
-
64
- cfg.fire_callback(:on_error, job, error)
65
- end
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
- scheduler.define_singleton_method(:on_post_trigger) do |job, time|
78
- cfg.logger.info("Finished task: #{extract_job_name(job)}")
79
+ def build_locker
80
+ return nil if config.standalone?
81
+ return nil if config.dedup_enabled?
79
82
 
80
- cfg.fire_callback(:after_finish, job, time)
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
- scheduler.shutdown(wait: 5) if @shutdown
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
- scheduler.instance_eval(schedule)
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