usps-support 0.2.49 → 0.2.50
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/app/controllers/usps/support/health_controller.rb +91 -31
- data/lib/usps/support/health_monitor.rb +92 -0
- data/lib/usps/support/version.rb +1 -1
- data/lib/usps/support.rb +1 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 634d21f58442e20b6f15886c8b0b91e92a0308d2d455422e9970fca70cf6e8ed
|
|
4
|
+
data.tar.gz: fdd939321295a783fea487db554a5009cde3b42e612fa67e3137b29eff9ee5e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 20322eb372cbca9ede2c3e02d8be07e98d0e39f0a4e3bb5775c9d598c01c17a2fc7bc706c6dffb8969012bd2b09a423202886a27ba030649017cc39a13f81795
|
|
7
|
+
data.tar.gz: d6622c12395f58e9427c058351de395188bdd08d6206d411b0f5b22ab2b1391149738f6a7ba061fa0e1f8dd04d5df9d29f6a03963a049a9c8284f6fe8724b9ce
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'net/http'
|
|
4
|
+
require 'timeout'
|
|
4
5
|
|
|
5
6
|
module Usps::Support
|
|
6
7
|
# Base controller for external synthetic health probes (see the infrastructure repo's
|
|
7
8
|
# doc/application_health_probes.md). Unlike Rails' /up (boot-only) and nginx's static
|
|
8
9
|
# /instance-health-check, this exercises an app's *critical dependencies* and returns the sentinel
|
|
9
|
-
# "HEALTHCHECK_OK <app>" only when every check passes
|
|
10
|
-
#
|
|
11
|
-
# with no organic traffic.
|
|
10
|
+
# "HEALTHCHECK_OK <app>" only when every check passes, so a Route 53 HTTPS_STR_MATCH probe
|
|
11
|
+
# asserting that prefix flips the Statuspage component even with no organic traffic.
|
|
12
12
|
#
|
|
13
13
|
# Host apps subclass this and declare which dependencies to probe with a CHECKS constant — a list
|
|
14
14
|
# of symbols, each naming a #check_<symbol> method:
|
|
@@ -17,15 +17,18 @@ module Usps::Support
|
|
|
17
17
|
# CHECKS = %i[database redis hq_database imis].freeze
|
|
18
18
|
# end
|
|
19
19
|
#
|
|
20
|
-
# and route to it (`get 'health' => 'health#show'`).
|
|
21
|
-
#
|
|
22
|
-
# responds. Apps run only the checks they list — admin-console has no HQ connection, so it omits
|
|
23
|
-
# :hq_database; an iMIS-backed app adds :imis. For a bespoke dependency, define a #check_<name> in
|
|
24
|
-
# the subclass (e.g. wrapping #check_http(url)) and add <name> to CHECKS.
|
|
20
|
+
# and route to it (`get 'health' => 'health#show'`). For a bespoke dependency, define a
|
|
21
|
+
# #check_<name> in the subclass (e.g. wrapping #check_http(url)) and add <name> to CHECKS.
|
|
25
22
|
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
23
|
+
# Checks run on a background thread, not the request thread: a per-process refresher updates a
|
|
24
|
+
# cached snapshot every health_refresh_interval seconds and #show serves that snapshot, so a slow
|
|
25
|
+
# or wedged dependency can't tie up the Passenger worker pool. Each check also runs under
|
|
26
|
+
# health_check_timeout, and a raise or timeout reads as a failed check (its error captured) so the
|
|
27
|
+
# action always responds. See doc/health_check_refresh_design.md.
|
|
28
|
+
#
|
|
29
|
+
# Per-component results (ok, latency, error) are handed to Usps::Support::HealthDiagnostics once
|
|
30
|
+
# per refresh which — when enabled — publishes them to CloudWatch so a past failure can be
|
|
31
|
+
# attributed to a specific component after it has recovered.
|
|
29
32
|
#
|
|
30
33
|
# Inherits ActionController::Base directly — NOT the host's ApplicationController — to bypass
|
|
31
34
|
# Devise auth, Pundit authorization, PaperTrail, and especially `allow_browser`, whose
|
|
@@ -34,47 +37,103 @@ module Usps::Support
|
|
|
34
37
|
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
|
35
38
|
SENTINEL_PREFIX = 'HEALTHCHECK_OK'
|
|
36
39
|
|
|
40
|
+
# Raised when a single check overruns the per-check timeout; recorded as a failed check.
|
|
41
|
+
class CheckTimeout < StandardError; end
|
|
42
|
+
|
|
37
43
|
# Symbols naming the dependency checks to run; each invokes the matching #check_<symbol>.
|
|
38
44
|
# Override per app by defining a CHECKS constant. Default probes the primary database only,
|
|
39
45
|
# which presumably every app needs.
|
|
40
46
|
CHECKS = %i[database].freeze
|
|
41
47
|
|
|
48
|
+
# Tunables (overridable per subclass, e.g. `self.health_check_timeout = 1` or in a test):
|
|
49
|
+
# health_check_timeout — per-check budget in seconds; a check that overruns is a failure
|
|
50
|
+
# health_refresh_interval — seconds between background refreshes of the cached snapshot
|
|
51
|
+
# health_snapshot_max_age — a cached snapshot older than this is refreshed inline before serving
|
|
52
|
+
# health_background_refresh — run the background refresher (false in tests for synchronous runs)
|
|
53
|
+
class_attribute :health_check_timeout, default: 2
|
|
54
|
+
class_attribute :health_refresh_interval, default: 15
|
|
55
|
+
class_attribute :health_snapshot_max_age, default: 45
|
|
56
|
+
class_attribute :health_background_refresh, default: true
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# One monitor per concrete subclass, per process. Built lazily so the background thread starts
|
|
60
|
+
# after Passenger forks its workers (threads do not survive fork).
|
|
61
|
+
def health_monitor
|
|
62
|
+
klass = self
|
|
63
|
+
@health_monitor ||= HealthMonitor.new(
|
|
64
|
+
interval: health_refresh_interval,
|
|
65
|
+
max_age: health_snapshot_max_age,
|
|
66
|
+
background: health_background_refresh,
|
|
67
|
+
runner: -> { klass.new.send(:run_and_record) }
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Drops the memoized monitor (stopping its thread). For tests.
|
|
72
|
+
def reset_health_monitor!
|
|
73
|
+
@health_monitor&.stop
|
|
74
|
+
@health_monitor = nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
42
78
|
def show
|
|
43
|
-
|
|
44
|
-
|
|
79
|
+
data = self.class.health_monitor.current
|
|
80
|
+
return render_unavailable unless data
|
|
45
81
|
|
|
46
|
-
|
|
47
|
-
|
|
82
|
+
render_health(data)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Renders the sentinel line, per-check status lines, and cache/retry headers from a snapshot.
|
|
88
|
+
def render_health(data)
|
|
89
|
+
healthy = data[:results].values.all?
|
|
90
|
+
|
|
91
|
+
body = "#{healthy ? "#{SENTINEL_PREFIX} #{data[:app]}" : "HEALTHCHECK_FAIL #{data[:app]}"}\n"
|
|
92
|
+
data[:results].each { |name, ok| body << "#{name}=#{ok ? 'ok' : 'fail'}\n" }
|
|
48
93
|
|
|
49
94
|
response.set_header('Cache-Control', 'no-store')
|
|
50
95
|
response.set_header('Retry-After', '30') unless healthy
|
|
51
|
-
HealthDiagnostics.record(app: app_name, components: @measurements)
|
|
52
96
|
render(plain: body, content_type: 'text/plain', status: healthy ? :ok : :service_unavailable)
|
|
53
97
|
end
|
|
54
98
|
|
|
55
|
-
|
|
99
|
+
# Fallback for the (rare) case where not even the first refresh could produce a snapshot.
|
|
100
|
+
def render_unavailable
|
|
101
|
+
response.set_header('Cache-Control', 'no-store')
|
|
102
|
+
response.set_header('Retry-After', '30')
|
|
103
|
+
render(plain: "HEALTHCHECK_FAIL #{app_name}\nsnapshot=unavailable\n",
|
|
104
|
+
content_type: 'text/plain', status: :service_unavailable)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# One round of checks + diagnostics, invoked by the monitor's runner (background thread, or the
|
|
108
|
+
# inline cold/stale path). Wrapped in the Rails executor so ActiveRecord connections checked out
|
|
109
|
+
# by the DB checks are returned even though this runs outside a request. Returns the render data.
|
|
110
|
+
def run_and_record
|
|
111
|
+
data = nil
|
|
112
|
+
::Rails.application.executor.wrap { data = perform_checks }
|
|
113
|
+
HealthDiagnostics.record(app: data[:app], components: data[:measurements])
|
|
114
|
+
data
|
|
115
|
+
end
|
|
56
116
|
|
|
57
|
-
# Runs each configured check, returning { label => boolean }
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
self.class::CHECKS.index_with { |name| measure(name) }
|
|
117
|
+
# Runs each configured check, returning { app:, results: { label => boolean }, measurements: }.
|
|
118
|
+
def perform_checks
|
|
119
|
+
measurements = []
|
|
120
|
+
results = self.class::CHECKS.index_with { |name| measure(name, measurements) }
|
|
121
|
+
{ app: app_name, results:, measurements: }
|
|
63
122
|
end
|
|
64
123
|
|
|
65
|
-
# Runs a single named check, timing it and recording its outcome. A
|
|
66
|
-
# failed check
|
|
67
|
-
# rather than a 500.
|
|
68
|
-
def measure(name)
|
|
124
|
+
# Runs a single named check under the per-check timeout, timing it and recording its outcome. A
|
|
125
|
+
# raised error or a timeout reads as a failed check (its class/message captured), so a dependency
|
|
126
|
+
# that blows up or hangs is attributable rather than a 500 or a stalled worker.
|
|
127
|
+
def measure(name, measurements)
|
|
69
128
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
129
|
begin
|
|
71
|
-
ok = send(:"check_#{name}") ? true : false
|
|
130
|
+
ok = Timeout.timeout(health_check_timeout, CheckTimeout) { send(:"check_#{name}") } ? true : false
|
|
72
131
|
error = nil
|
|
73
132
|
rescue StandardError => e
|
|
74
133
|
ok = false
|
|
75
134
|
error = "#{e.class}: #{e.message}"
|
|
76
135
|
end
|
|
77
|
-
|
|
136
|
+
measurements << { label: name.to_s, ok:, latency_ms: elapsed_ms(started), error: }
|
|
78
137
|
ok
|
|
79
138
|
end
|
|
80
139
|
|
|
@@ -85,8 +144,9 @@ module Usps::Support
|
|
|
85
144
|
def app_name = Rails.application.class.module_parent_name.underscore.dasherize
|
|
86
145
|
|
|
87
146
|
# --- Check primitives. Each returns true on success and returns false or raises on failure;
|
|
88
|
-
# #measure catches the raise, so a dead dependency reads as a failed check (with
|
|
89
|
-
# captured) rather than a 500. Named check_<name>, not <name>?, to drive CHECKS
|
|
147
|
+
# #measure catches the raise/timeout, so a dead or slow dependency reads as a failed check (with
|
|
148
|
+
# its error captured) rather than a 500. Named check_<name>, not <name>?, to drive CHECKS
|
|
149
|
+
# dispatch. ---
|
|
90
150
|
|
|
91
151
|
# rubocop:disable Naming/PredicateMethod
|
|
92
152
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usps
|
|
4
|
+
module Support
|
|
5
|
+
# Keeps a process-local snapshot of the latest health-check results so /health can answer from
|
|
6
|
+
# memory instead of running the checks on the request thread. See
|
|
7
|
+
# doc/health_check_refresh_design.md for the rationale.
|
|
8
|
+
#
|
|
9
|
+
# runner is a callable that runs one full round of checks and returns whatever the caller needs
|
|
10
|
+
# to render (HealthController returns { app:, results:, measurements: }). It is expected to bound
|
|
11
|
+
# its own checks; the monitor adds no timeout of its own.
|
|
12
|
+
#
|
|
13
|
+
# One monitor exists per concrete HealthController subclass, per process. It is created lazily on
|
|
14
|
+
# the first request, so the background thread starts after Passenger forks its workers (threads
|
|
15
|
+
# do not survive fork).
|
|
16
|
+
class HealthMonitor
|
|
17
|
+
Snapshot = Struct.new(:data, :at, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
# interval: seconds between background refreshes
|
|
20
|
+
# max_age: a snapshot older than this is refreshed inline before use, so a wedged or
|
|
21
|
+
# never-started background thread can't serve dangerously old data
|
|
22
|
+
# runner: callable that runs one round of checks and returns the render data
|
|
23
|
+
# background: start the background refresher (false in tests, so refreshes are synchronous)
|
|
24
|
+
# clock: monotonic clock (seconds) to read; injectable for deterministic staleness tests
|
|
25
|
+
def initialize(interval:, max_age:, runner:, background: true, clock: nil)
|
|
26
|
+
@interval = interval
|
|
27
|
+
@max_age = max_age
|
|
28
|
+
@runner = runner
|
|
29
|
+
@background = background
|
|
30
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The latest results. Serves a fresh cached snapshot without touching any dependency; on a cold
|
|
35
|
+
# (first call) or stale snapshot, refreshes inline so the answer is never dangerously old. Also
|
|
36
|
+
# starts the background refresher on first use. Returns the runner's data, or nil only if the
|
|
37
|
+
# very first refresh failed and there is no prior snapshot to fall back on.
|
|
38
|
+
def current
|
|
39
|
+
ensure_running if @background
|
|
40
|
+
|
|
41
|
+
snapshot = @snapshot
|
|
42
|
+
return snapshot.data if snapshot && fresh?(snapshot)
|
|
43
|
+
|
|
44
|
+
refresh
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Runs one round of checks and stores the snapshot; returns the fresh data. On a runner error,
|
|
48
|
+
# keeps the last good snapshot and returns its data (nil if there was none), so a transient
|
|
49
|
+
# failure to run the checks never blanks out /health.
|
|
50
|
+
def refresh
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
data = @runner.call
|
|
53
|
+
@snapshot = Snapshot.new(data:, at: @clock.call)
|
|
54
|
+
data
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError
|
|
57
|
+
@snapshot&.data
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fresh?(snapshot) = (@clock.call - snapshot.at) <= @max_age
|
|
61
|
+
|
|
62
|
+
# Stops the background refresher. Used by tests; also safe to call on shutdown.
|
|
63
|
+
def stop
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
@thread&.kill
|
|
66
|
+
@thread = nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Starts the background refresher thread; exercised in prod (and the background spec) rather
|
|
73
|
+
# than counted as a unit, matching HealthDiagnostics' delivery worker.
|
|
74
|
+
# :nocov:
|
|
75
|
+
def ensure_running
|
|
76
|
+
return if @thread&.alive?
|
|
77
|
+
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
return if @thread&.alive?
|
|
80
|
+
|
|
81
|
+
@thread = Thread.new do
|
|
82
|
+
loop do
|
|
83
|
+
refresh
|
|
84
|
+
sleep(@interval)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
# :nocov:
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/usps/support/version.rb
CHANGED
data/lib/usps/support.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative 'support/helpers'
|
|
|
18
18
|
require_relative 'support/models'
|
|
19
19
|
require_relative 'support/lib'
|
|
20
20
|
require_relative 'support/health_diagnostics'
|
|
21
|
+
require_relative 'support/health_monitor'
|
|
21
22
|
|
|
22
23
|
# The Engine require lives in `usps/all`, not here. This file can
|
|
23
24
|
# legitimately be required before Rails is loaded (e.g. from .simplecov to pull
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: usps-support
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.50
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julian Fiander
|
|
@@ -46,7 +46,7 @@ dependencies:
|
|
|
46
46
|
version: '0'
|
|
47
47
|
- - ">="
|
|
48
48
|
- !ruby/object:Gem::Version
|
|
49
|
-
version: 0.
|
|
49
|
+
version: 0.15.0
|
|
50
50
|
type: :runtime
|
|
51
51
|
prerelease: false
|
|
52
52
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -56,7 +56,7 @@ dependencies:
|
|
|
56
56
|
version: '0'
|
|
57
57
|
- - ">="
|
|
58
58
|
- !ruby/object:Gem::Version
|
|
59
|
-
version: 0.
|
|
59
|
+
version: 0.15.0
|
|
60
60
|
- !ruby/object:Gem::Dependency
|
|
61
61
|
name: usps-jwt_auth
|
|
62
62
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -98,6 +98,7 @@ files:
|
|
|
98
98
|
- lib/usps/support/db/websites_schema.rb
|
|
99
99
|
- lib/usps/support/engine.rb
|
|
100
100
|
- lib/usps/support/health_diagnostics.rb
|
|
101
|
+
- lib/usps/support/health_monitor.rb
|
|
101
102
|
- lib/usps/support/helpers.rb
|
|
102
103
|
- lib/usps/support/helpers/badges_helper.rb
|
|
103
104
|
- lib/usps/support/helpers/flags_helper.rb
|