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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5764f3cd03bbd09cf0cc9be4873c9ecba705c6f4a5676058a6577de01999a66f
4
- data.tar.gz: 50784833a152814d04f12ad8785f2d4c6199b0b88af40ead54c7d0e8c6a0cdf9
3
+ metadata.gz: 634d21f58442e20b6f15886c8b0b91e92a0308d2d455422e9970fca70cf6e8ed
4
+ data.tar.gz: fdd939321295a783fea487db554a5009cde3b42e612fa67e3137b29eff9ee5e8
5
5
  SHA512:
6
- metadata.gz: 1ad1f146fd493edbeb08d110abb5de5b825cb5ab93ef494c6f8e12b554615a056940acf8d9ef9a3f39f64fb6ea2adba5ac7f8870282cecd4dd838f2aa01547fd
7
- data.tar.gz: 6d0a9a31c61db44949f1852853f53e2eb29b6b47cb2aa828398cdc1492f81d29c511d250eae688463a5f018b418a64949bd071bec798db7bd8ff23cb790b98db
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 a Route 53 HTTPS_STR_MATCH probe asserts
10
- # the "HEALTHCHECK_OK" prefix, so a quiet app-level failure flips the Statuspage component even
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'`). Each check_* primitive returns a boolean or
21
- # raises; the base times every check and treats a raise as a failed check, so the action always
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
- # Every probe's per-component results (ok, latency, error) are also handed to
27
- # Usps::Support::HealthDiagnostics, which when enabled publishes them to CloudWatch so a past
28
- # failure can be attributed to a specific component after it has recovered.
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
- results = checks
44
- healthy = results.values.all?
79
+ data = self.class.health_monitor.current
80
+ return render_unavailable unless data
45
81
 
46
- body = "#{healthy ? "#{SENTINEL_PREFIX} #{app_name}" : "HEALTHCHECK_FAIL #{app_name}"}\n"
47
- results.each { |name, ok| body << "#{name}=#{ok ? 'ok' : 'fail'}\n" }
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
- private
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 }. Reads the subclass's CHECKS
58
- # constant, so apps pick checks by listing symbols rather than overriding this method. Each run
59
- # is timed and its outcome captured in @measurements for HealthDiagnostics.
60
- def checks
61
- @measurements = []
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 raised error reads as a
66
- # failed check and its class/message is captured, so a dependency that blows up is attributable
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
- @measurements << { label: name.to_s, ok:, latency_ms: elapsed_ms(started), error: }
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 its error
89
- # captured) rather than a 500. Named check_<name>, not <name>?, to drive CHECKS dispatch. ---
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Usps
4
4
  module Support
5
- VERSION = '0.2.49'
5
+ VERSION = '0.2.50'
6
6
  end
7
7
  end
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.49
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.14.1
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.14.1
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