schked 1.4.0 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a45abd806085b8336543dfff30a298b8f1148e0ee0642cd4896b8e6f5a721c3
4
- data.tar.gz: b2d6030f651c9a86e573038c88a30f26efbd6bc840ff212aab9baa5cbb3b4fab
3
+ metadata.gz: 4d392bfee50671c6bc87ab00b4cbdb29969e55544d35a6133ce23b24f1d3171e
4
+ data.tar.gz: 196a09231bd9abb1c0a83f7d4d6d4ecb140cb3b305e1710aa775f21690ae3eb7
5
5
  SHA512:
6
- metadata.gz: 8a13bb8431aaf97845d49c92e8410dc65d8a3308cb34ce129ce060c3dea5673fbc254c3bdcb461a1e4049020d71dbd16894fd0fe25be1fd53faab491b0077f36
7
- data.tar.gz: 18289f5de7000436ebfc4249b3b2de3781dcf61873c94442d93584f3e362652bcef7ede7ba4e08355692f3a4d479cd149579ca19b3c99360c632787defdf23e9
6
+ metadata.gz: '02810b0f6a3717db6e4f5aa4cee9eee4644c74c7a583fbc1c0e6acae97d63193366c1ecbd0ee087106acbe542a49ace4e5cdfe209aba0b11c3d31938ef088638'
7
+ data.tar.gz: b55de68f347b8be57cc32cbf13ef582cd4ac33c8cddb881bfcead7bc449454953147c20bd311a03bfaa38c444884e6bf45daa7b54d2bb4bcbad5e2207d50194e
data/README.md CHANGED
@@ -124,6 +124,54 @@ config/initializers/schked.rb
124
124
  Schked.config.logger = Logger.new(Rails.root.join("log", "schked.log"))
125
125
  ```
126
126
 
127
+ ### Liveness probe
128
+
129
+ Schked can expose a small HTTP endpoint for Kubernetes liveness probes. It is **disabled by default** to keep the existing behavior unchanged.
130
+
131
+ Configure it in Ruby:
132
+
133
+ ```ruby
134
+ Schked.config.liveness_probe = {
135
+ enabled: true,
136
+ bind: "0.0.0.0",
137
+ port: 8080,
138
+ path: "/healthz",
139
+ heartbeat_interval: 5,
140
+ heartbeat_threshold: 15
141
+ }
142
+ ```
143
+
144
+ Or via CLI flags:
145
+
146
+ ```sh
147
+ bundle exec schked start --liveness-probe --liveness-bind 0.0.0.0 --liveness-port 8080 --liveness-path /healthz
148
+ ```
149
+
150
+ In Rails, set it through the application config:
151
+
152
+ ```ruby
153
+ # config/application.rb or config/environments/*.rb
154
+ config.schked.liveness_probe = {
155
+ enabled: true,
156
+ bind: "0.0.0.0",
157
+ port: 8080,
158
+ path: "/healthz",
159
+ heartbeat_interval: 5,
160
+ heartbeat_threshold: 15
161
+ }
162
+ ```
163
+
164
+ The endpoint returns `200 OK` while the scheduler is responsive and `503 Service Unavailable` when the heartbeat is stale or during shutdown. The scheduler updates the heartbeat every `heartbeat_interval` seconds (default `5`); if it is not updated within `heartbeat_threshold` seconds (default `15`), the endpoint reports unhealthy. Use it in Kubernetes like this:
165
+
166
+ ```yaml
167
+ livenessProbe:
168
+ httpGet:
169
+ path: /healthz
170
+ port: 8080
171
+ initialDelaySeconds: 10
172
+ periodSeconds: 10
173
+ ```
174
+
127
175
  ### Monitoring
128
176
 
129
177
  [Yabeda::Schked](https://github.com/yabeda-rb/yabeda-schked) - built-in metrics for monitoring Schked recurring jobs out of the box! Part of the [yabeda](https://github.com/yabeda-rb/yabeda) suite.
data/lib/schked/cli.rb CHANGED
@@ -15,7 +15,7 @@ module Schked
15
15
  .shellsplit
16
16
  end
17
17
 
18
- super(argv)
18
+ super
19
19
  end
20
20
 
21
21
  def self.exit_on_failure?
@@ -26,8 +26,13 @@ module Schked
26
26
 
27
27
  desc "start", "Start scheduler"
28
28
  option :require, type: :array
29
+ option :liveness_probe, type: :boolean, default: nil, desc: "Enable or disable the liveness probe"
30
+ option :liveness_bind, type: :string, desc: "Address the liveness probe binds to"
31
+ option :liveness_port, type: :numeric, desc: "Port the liveness probe listens on"
32
+ option :liveness_path, type: :string, desc: "HTTP path for the liveness probe"
29
33
  def start
30
34
  load_requires
35
+ apply_liveness_probe_options
31
36
 
32
37
  Schked.worker.wait
33
38
  end
@@ -50,5 +55,15 @@ module Schked
50
55
  # We have to load Schked at here, because of Rails and our railtie.
51
56
  require "schked"
52
57
  end
58
+
59
+ def apply_liveness_probe_options
60
+ overrides = {}
61
+ overrides[:enabled] = options[:liveness_probe] unless options[:liveness_probe].nil?
62
+ overrides[:bind] = options[:liveness_bind] if options[:liveness_bind]
63
+ overrides[:port] = options[:liveness_port] if options[:liveness_port]
64
+ overrides[:path] = options[:liveness_path] if options[:liveness_path]
65
+
66
+ Schked.config.liveness_probe = Schked.config.liveness_probe.to_h.merge(overrides) if overrides.any?
67
+ end
53
68
  end
54
69
  end
data/lib/schked/config.rb CHANGED
@@ -9,6 +9,14 @@ module Schked
9
9
  :redis,
10
10
  :standalone
11
11
 
12
+ def liveness_probe
13
+ @liveness_probe ||= LivenessProbeConfig.new
14
+ end
15
+
16
+ def liveness_probe=(value)
17
+ @liveness_probe = value.is_a?(LivenessProbeConfig) ? value : LivenessProbeConfig.new(value)
18
+ end
19
+
12
20
  def paths
13
21
  @paths ||= []
14
22
  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
@@ -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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Schked
4
- VERSION = "1.4.0"
4
+ VERSION = "1.5.0"
5
5
  end
data/lib/schked/worker.rb CHANGED
@@ -6,6 +6,7 @@ module Schked
6
6
  class Worker
7
7
  def initialize(config:)
8
8
  @config = config
9
+ @liveness_probe = nil
9
10
 
10
11
  @locker = RedisLocker.new(config.redis, lock_ttl: 40_000, logger: config.logger) unless config.standalone?
11
12
 
@@ -15,6 +16,7 @@ module Schked
15
16
  define_callbacks
16
17
  define_extend_lock unless config.standalone?
17
18
  load_schedule
19
+ start_liveness_probe
18
20
  end
19
21
 
20
22
  def job(as)
@@ -30,6 +32,7 @@ module Schked
30
32
  end
31
33
 
32
34
  def stop
35
+ liveness_probe&.stop
33
36
  scheduler.stop
34
37
  end
35
38
 
@@ -44,7 +47,7 @@ module Schked
44
47
 
45
48
  private
46
49
 
47
- attr_reader :config, :scheduler, :locker
50
+ attr_reader :config, :scheduler, :locker, :liveness_probe
48
51
 
49
52
  def define_callbacks
50
53
  cfg = config
@@ -94,7 +97,10 @@ module Schked
94
97
 
95
98
  Thread.new do
96
99
  loop do
97
- scheduler.shutdown(wait: 5) if @shutdown
100
+ if @shutdown
101
+ liveness_probe&.stop
102
+ scheduler.shutdown(wait: 5)
103
+ end
98
104
  sleep 1
99
105
  end
100
106
  end
@@ -109,5 +115,18 @@ module Schked
109
115
  def load_schedule
110
116
  scheduler.instance_eval(schedule)
111
117
  end
118
+
119
+ def start_liveness_probe
120
+ return unless config.liveness_probe.enabled
121
+
122
+ @liveness_probe = LivenessProbe.new(config: config.liveness_probe, logger: config.logger)
123
+ @liveness_probe.start
124
+
125
+ scheduler.every("#{config.liveness_probe.heartbeat_interval}s", as: "Schked::Worker#liveness_heartbeat", overlap: false) do
126
+ @liveness_probe.heartbeat
127
+ end
128
+
129
+ @liveness_probe.heartbeat
130
+ end
112
131
  end
113
132
  end
data/lib/schked.rb CHANGED
@@ -4,6 +4,7 @@ require "connection_pool"
4
4
  require "redlock"
5
5
 
6
6
  require "schked/version"
7
+ require "schked/liveness_probe"
7
8
  require "schked/config"
8
9
  require "schked/worker"
9
10
  require "schked/redis_locker"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schked
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Misha Merkushin
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: connection_pool
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -175,14 +189,14 @@ dependencies:
175
189
  requirements:
176
190
  - - "~>"
177
191
  - !ruby/object:Gem::Version
178
- version: '0.4'
192
+ version: '1.37'
179
193
  type: :development
180
194
  prerelease: false
181
195
  version_requirements: !ruby/object:Gem::Requirement
182
196
  requirements:
183
197
  - - "~>"
184
198
  - !ruby/object:Gem::Version
185
- version: '0.4'
199
+ version: '1.37'
186
200
  description: Rufus-scheduler wrapper to run recurring jobs
187
201
  email:
188
202
  - merkushin.m.s@gmail.com
@@ -197,6 +211,7 @@ files:
197
211
  - lib/schked.rb
198
212
  - lib/schked/cli.rb
199
213
  - lib/schked/config.rb
214
+ - lib/schked/liveness_probe.rb
200
215
  - lib/schked/railtie.rb
201
216
  - lib/schked/redis_client_factory.rb
202
217
  - lib/schked/redis_locker.rb