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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b6c4b733379c3005f044d0d8e780dcb4b9f1c37124f97ff61eaf3ab5844ba81
|
|
4
|
+
data.tar.gz: 00c8be1ab0bc6fa06aac0a195a431ed055300d1be7e44441a13d106045bfc01d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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:
|
|
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
|
-
#
|
|
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
|
data/lib/usps/support/version.rb
CHANGED
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.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
|