usps-support 0.2.50 → 0.2.51

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: 634d21f58442e20b6f15886c8b0b91e92a0308d2d455422e9970fca70cf6e8ed
4
- data.tar.gz: fdd939321295a783fea487db554a5009cde3b42e612fa67e3137b29eff9ee5e8
3
+ metadata.gz: 3b6c4b733379c3005f044d0d8e780dcb4b9f1c37124f97ff61eaf3ab5844ba81
4
+ data.tar.gz: 00c8be1ab0bc6fa06aac0a195a431ed055300d1be7e44441a13d106045bfc01d
5
5
  SHA512:
6
- metadata.gz: 20322eb372cbca9ede2c3e02d8be07e98d0e39f0a4e3bb5775c9d598c01c17a2fc7bc706c6dffb8969012bd2b09a423202886a27ba030649017cc39a13f81795
7
- data.tar.gz: d6622c12395f58e9427c058351de395188bdd08d6206d411b0f5b22ab2b1391149738f6a7ba061fa0e1f8dd04d5df9d29f6a03963a049a9c8284f6fe8724b9ce
6
+ metadata.gz: 1bfca2dd2afc4080aa03b98fb2c1fa88d3049e1c9db9415ae6bd096b14196135718f1846af24900e6db82f82a8c30c3055bd866aa5e187666def7999834b03b7
7
+ data.tar.gz: 758b292a1469fd630c0b2281f5ee9db88d0b97a96d2339c19983c4aabd9e1426f12a54762c7547e8b8217abc80d3a6b4629984aa7e52457b7d0475dae4843455
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usps::Support
4
+ # Fails requests fast, with the branded 503 page, while a dependency the app cannot work without
5
+ # is known to be down — instead of letting every request discover that for itself by blocking on
6
+ # the dead dependency.
7
+ #
8
+ # ## Why
9
+ #
10
+ # The HQ member database is in the request path of *every authenticated request*:
11
+ # `authenticate_user_from_jwt!` resolves the JWT's certificate through `Members::Member.find`.
12
+ # While HQ is unreachable each of those requests blocks until the database client's connect
13
+ # timeout expires, then raises a connection error that ApplicationController rescues into the 503
14
+ # page. The page is correct; paying a connect timeout to produce it is what hurts. The web tier
15
+ # serves a small, fixed number of requests concurrently, so a per-request stall of seconds is
16
+ # enough to occupy every worker: requests queue, nginx gives up on the upstream, and an app that
17
+ # is *up and answering correctly* reads to users as hard down.
18
+ #
19
+ # This gate closes that gap: the app already *knows* HQ is down, because HealthController's
20
+ # monitor probes it on a background thread and leaves the verdict in memory (see
21
+ # health_check_refresh_design.md). Consulting that costs no I/O and no worker time, so the request
22
+ # returns the same 503 immediately instead of after a timeout.
23
+ #
24
+ # ## Contract
25
+ #
26
+ # - **/health is untouched.** The gate is a *reader* of the snapshot; it never runs a check, never
27
+ # changes what /health probes, renders, or reports to CloudWatch. The synthetic probes and
28
+ # Statuspage components keep behaving exactly as they do today — the app's own gating must never
29
+ # become the thing that decides whether we call ourselves up.
30
+ # - **Never blocks.** It reads the last completed snapshot only (HealthMonitor#cached), so a
31
+ # process that has not yet run a round serves the request normally rather than paying for a
32
+ # round on the request thread.
33
+ # - **Fails open.** No snapshot, a stale one (a wedged refresher), or any error reading it means
34
+ # "no opinion" and the request proceeds — the pre-existing `rescue_from` for the connection
35
+ # errors is still the backstop, so failing open costs latency, never correctness.
36
+ # - **Requires a sustained failure.** Gating opens only after `dependency_gate_threshold`
37
+ # consecutive failed rounds, so a single slow round — one that merely overran its check budget —
38
+ # can't blackhole a healthy app. It closes again on the first passing round, with no deploy or
39
+ # restart needed.
40
+ #
41
+ # ## Wiring
42
+ #
43
+ # class ApplicationController < ActionController::Base
44
+ # include Usps::Support::DependencyGate
45
+ # self.dependency_gate_title = 'Exams System'
46
+ # end
47
+ #
48
+ # `gated_dependencies` defaults to `%i[hq_database]` — the only check that is in the user request
49
+ # path. iMIS deliberately is not: it is an external service reached outside the VPN and is touched
50
+ # only by rescued admin reports and Sidekiq jobs, so an iMIS outage must not gate the app.
51
+ #
52
+ # Actions that genuinely do not need the gated dependency opt out the same way they opt out of
53
+ # authentication, and for the same reason — keeping work that still functions during an HQ outage
54
+ # (e.g. the unauthenticated online-examination flow, which lives entirely in RDS) working:
55
+ #
56
+ # skip_before_action :require_gated_dependencies!, only: %i[exam submit_response]
57
+ #
58
+ module DependencyGate
59
+ extend ActiveSupport::Concern
60
+
61
+ included do
62
+ # Checks (by their HealthController CHECKS name) that must be up for a request to be served.
63
+ class_attribute :gated_dependencies, default: %i[hq_database]
64
+
65
+ # Consecutive failed check rounds before the gate opens; rounds are health_refresh_interval
66
+ # apart, so this trades detection speed against tolerance for a one-off slow round.
67
+ class_attribute :dependency_gate_threshold, default: 2
68
+
69
+ # Title for the rendered error page, matching what the rescue path uses ('Exams System').
70
+ class_attribute :dependency_gate_title, default: nil
71
+
72
+ # The app's HealthController subclass, whose monitor owns the snapshot. Resolved lazily (and
73
+ # by name) so this loads cleanly in an app whose controllers autoload later.
74
+ class_attribute :dependency_gate_health_controller, default: -> { ::HealthController }
75
+
76
+ # Runs before every other filter — notably before authentication, which is itself what reaches
77
+ # HQ and would block.
78
+ prepend_before_action :require_gated_dependencies!
79
+ end
80
+
81
+ # Logs gate open/close transitions once per process rather than once per request: during an
82
+ # outage every request is gated, and a per-request line would bury everything else in the log.
83
+ # A race between threads can duplicate a line; that is cheaper than a lock on the request path.
84
+ def self.log_transition(down)
85
+ return if @down == down
86
+
87
+ was_open = @down.present?
88
+ @down = down
89
+
90
+ if down.any?
91
+ ::Rails.logger.warn("[dependency-gate] open: #{down.join(', ')} down, serving 503 until recovered")
92
+ elsif was_open
93
+ ::Rails.logger.warn('[dependency-gate] closed: gated dependencies recovered, serving normally')
94
+ end
95
+ # A process whose first request is already healthy has nothing to announce.
96
+ end
97
+
98
+ # Forgets the last logged state. For tests.
99
+ def self.reset_transition_log! = @down = nil
100
+
101
+ private
102
+
103
+ def require_gated_dependencies!
104
+ down = unavailable_dependencies
105
+ DependencyGate.log_transition(down)
106
+ return if down.empty?
107
+
108
+ render_dependencies_unavailable
109
+ end
110
+
111
+ # Gated dependencies whose failure has persisted for at least the threshold. Anything the
112
+ # snapshot has no entry for — unknown, cold, or stale — is absent from the status hash and so
113
+ # reads as available (fail open).
114
+ def unavailable_dependencies
115
+ status = dependency_gate_status
116
+
117
+ gated_dependencies.select { |name| status.dig(name, :consecutive_failures).to_i >= dependency_gate_threshold }
118
+ end
119
+
120
+ # Deliberately swallows everything: a gate that raises would turn a degraded app into a broken
121
+ # one, which is the opposite of the point.
122
+ def dependency_gate_status
123
+ dependency_gate_health_controller.call.dependency_status
124
+ rescue StandardError => e
125
+ ::Rails.logger.warn("[dependency-gate] status unavailable (#{e.class}: #{e.message}); serving normally")
126
+ {}
127
+ end
128
+
129
+ # The same branded 503 the ActiveRecord rescue path renders, so a gated request is
130
+ # indistinguishable to a user (and to a browser, which honours Retry-After) from one that
131
+ # discovered the outage the slow way. No Bugsnag notify: unlike the rescue path this fires on
132
+ # *every* request for the duration of an outage, and the CloudWatch alarm and Statuspage
133
+ # component already own that signal.
134
+ #
135
+ # The `error` layout is required — the application layout reads `current_user`, which would
136
+ # reconnect to the unavailable HQ database from inside the handler.
137
+ def render_dependencies_unavailable
138
+ @title = dependency_gate_title
139
+ response.set_header('Retry-After', '30')
140
+
141
+ respond_to do |format|
142
+ format.html { render('errors/service_unavailable', layout: 'error', status: :service_unavailable) }
143
+ format.json { render(json: { error: 'Service unavailable.' }, status: :service_unavailable) }
144
+ format.any { head(:service_unavailable) }
145
+ end
146
+ end
147
+ end
148
+ end
@@ -49,11 +49,15 @@ module Usps::Support
49
49
  # health_check_timeout — per-check budget in seconds; a check that overruns is a failure
50
50
  # health_refresh_interval — seconds between background refreshes of the cached snapshot
51
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)
52
+ # health_background_refresh — run the background refresher; off under test, where refreshes run
53
+ # synchronously and an unannounced thread probing real dependencies
54
+ # (DependencyGate starts one from any request) would be a surprise
55
+ # health_status_max_age — .dependency_status ignores a snapshot older than this (fails open)
53
56
  class_attribute :health_check_timeout, default: 2
54
57
  class_attribute :health_refresh_interval, default: 15
55
58
  class_attribute :health_snapshot_max_age, default: 45
56
- class_attribute :health_background_refresh, default: true
59
+ class_attribute :health_background_refresh, default: !::Rails.env.test?
60
+ class_attribute :health_status_max_age, default: 90
57
61
 
58
62
  class << self
59
63
  # One monitor per concrete subclass, per process. Built lazily so the background thread starts
@@ -68,10 +72,37 @@ module Usps::Support
68
72
  )
69
73
  end
70
74
 
71
- # Drops the memoized monitor (stopping its thread). For tests.
75
+ # Read-only, non-blocking view of the last completed round, for callers on a *user* request
76
+ # thread — specifically Usps::Support::DependencyGate, which uses it to fail a request fast
77
+ # rather than discovering the outage by blocking on the dead dependency itself:
78
+ #
79
+ # { hq_database: { ok: false, consecutive_failures: 4 }, database: { ok: true, ... } }
80
+ #
81
+ # Runs no check and takes no lock; it reads whatever the background refresher last stored.
82
+ # Returns {} — "no opinion", so callers fail *open* — when no round has completed yet (cold
83
+ # worker) or the snapshot has aged past health_status_max_age, which means the refresher itself
84
+ # is wedged and its last verdict is no longer evidence about the dependency.
85
+ def dependency_status
86
+ snapshot = health_monitor.cached
87
+ return {} unless snapshot && health_monitor.age_of(snapshot) <= health_status_max_age
88
+
89
+ snapshot.data&.[](:status) || {}
90
+ end
91
+
92
+ # Consecutive failed rounds per check, updated once per refresh so a gate can require a
93
+ # *sustained* failure and ignore a single slow round. Called only from the monitor's runner,
94
+ # and the monitor serialises refreshes under its own mutex, so this needs no lock of its own.
95
+ def record_streaks(results)
96
+ @streaks ||= Hash.new(0)
97
+ results.each { |name, ok| ok ? @streaks[name] = 0 : @streaks[name] += 1 }
98
+ results.to_h { |name, ok| [name, { ok:, consecutive_failures: @streaks[name] }] }
99
+ end
100
+
101
+ # Drops the memoized monitor (stopping its thread) and the failure streaks. For tests.
72
102
  def reset_health_monitor!
73
103
  @health_monitor&.stop
74
104
  @health_monitor = nil
105
+ @streaks = nil
75
106
  end
76
107
  end
77
108
 
@@ -114,11 +145,13 @@ module Usps::Support
114
145
  data
115
146
  end
116
147
 
117
- # Runs each configured check, returning { app:, results: { label => boolean }, measurements: }.
148
+ # Runs each configured check, returning { app:, results: { label => boolean }, measurements:,
149
+ # status: }. Only :results feeds the rendered body; :status carries the per-check failure streaks
150
+ # that .dependency_status hands to the request-path gate.
118
151
  def perform_checks
119
152
  measurements = []
120
153
  results = self.class::CHECKS.index_with { |name| measure(name, measurements) }
121
- { app: app_name, results:, measurements: }
154
+ { app: app_name, results:, measurements:, status: self.class.record_streaks(results) }
122
155
  end
123
156
 
124
157
  # Runs a single named check under the per-check timeout, timing it and recording its outcome. A
@@ -59,6 +59,22 @@ module Usps
59
59
 
60
60
  def fresh?(snapshot) = (@clock.call - snapshot.at) <= @max_age
61
61
 
62
+ # The last completed snapshot, or nil if none has been taken yet -- *without* refreshing, even
63
+ # when stale. #current is for /health, which must answer accurately and may pay for an inline
64
+ # round; this is for callers on a user request thread (Usps::Support::DependencyGate), where
65
+ # running a check would reintroduce exactly the stall they exist to avoid. Starts the
66
+ # background refresher on first use so a process that never serves /health still gets one.
67
+ # Callers judge staleness themselves via #age_of.
68
+ def cached
69
+ ensure_running if @background
70
+
71
+ @snapshot
72
+ end
73
+
74
+ # Seconds since the snapshot was taken, on the monitor's own (monotonic) clock -- callers can't
75
+ # compare `snapshot.at` against a wall clock of their own.
76
+ def age_of(snapshot) = @clock.call - snapshot.at
77
+
62
78
  # Stops the background refresher. Used by tests; also safe to call on shutdown.
63
79
  def stop
64
80
  @mutex.synchronize do
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Usps
4
4
  module Support
5
- VERSION = '0.2.50'
5
+ VERSION = '0.2.51'
6
6
  end
7
7
  end
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.50
4
+ version: 0.2.51
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Fiander
@@ -86,6 +86,7 @@ extra_rdoc_files: []
86
86
  files:
87
87
  - Readme.md
88
88
  - app/controllers/concerns/usps/support/admin_menu.rb
89
+ - app/controllers/concerns/usps/support/dependency_gate.rb
89
90
  - app/controllers/usps/support/admins_controller.rb
90
91
  - app/controllers/usps/support/health_controller.rb
91
92
  - app/models/usps/support/policy/admin_context.rb