findxpand 0.1.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 +7 -0
- data/README.md +325 -0
- data/exe/findxpand +20 -0
- data/findxpand.gemspec +71 -0
- data/lib/findxpand/admin.rb +157 -0
- data/lib/findxpand/auto.rb +607 -0
- data/lib/findxpand/cli.rb +214 -0
- data/lib/findxpand/encoding.rb +160 -0
- data/lib/findxpand/middleware.rb +597 -0
- data/lib/findxpand/railtie.rb +72 -0
- data/lib/findxpand/rewrite.rb +628 -0
- data/lib/findxpand/signature.rb +127 -0
- data/lib/findxpand/store.rb +657 -0
- data/lib/findxpand.rb +255 -0
- metadata +62 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The rule set, held in memory and persisted to one file.
|
|
4
|
+
#
|
|
5
|
+
# **Nothing here ever calls out.** Findxpand pushes to this process; this process
|
|
6
|
+
# never fetches from Findxpand. A middleware that blocks on a network call to a
|
|
7
|
+
# vendor has made that vendor's uptime the site's uptime for the sake of a title
|
|
8
|
+
# tag, and polling is the same coupling with a staleness window attached. The
|
|
9
|
+
# consequence worth holding in mind: if Findxpand is down, this keeps serving
|
|
10
|
+
# whatever it last stored, indefinitely.
|
|
11
|
+
#
|
|
12
|
+
# The cache file exists so a restart is not a regression. Without it every deploy
|
|
13
|
+
# would drop every rule until the next push, and the site would quietly revert to
|
|
14
|
+
# the titles the client is paying to change.
|
|
15
|
+
#
|
|
16
|
+
# **One store per process, shared by every attach point.** Ruby has more ways to
|
|
17
|
+
# get into the request path than Node or Python do — a Railtie, a builder
|
|
18
|
+
# prepend, and a hand-written `use` are all reachable in the same process — and
|
|
19
|
+
# two of them can be live at once. Two `Store` objects would mean two manifests,
|
|
20
|
+
# two sets of counters and a `/status` answering for whichever instance happened
|
|
21
|
+
# to own the admin path, so `Findxpand.store` is memoised and every instance uses
|
|
22
|
+
# it unless a test passes its own (§20.1 rule 2).
|
|
23
|
+
#
|
|
24
|
+
# Frozen string literals: on. Nothing here appends to a literal.
|
|
25
|
+
|
|
26
|
+
require 'json'
|
|
27
|
+
require 'fileutils'
|
|
28
|
+
|
|
29
|
+
module Findxpand
|
|
30
|
+
# The one degraded verdict, worded by the engine and shared with the Node and
|
|
31
|
+
# Python implementations.
|
|
32
|
+
#
|
|
33
|
+
# Mirrors `engine.fix.origin.DEGRADED_REASONS` and
|
|
34
|
+
# `middleware/node/src/manifest.ts`'s `degradedReason` branch for branch. Two
|
|
35
|
+
# independently-worded health checks are two that disagree the first time
|
|
36
|
+
# either is edited, and this one is the sentence a customer pastes into a
|
|
37
|
+
# support thread — `middleware/conformance/cases.json` carries the three
|
|
38
|
+
# templates and `test/conformance_test.rb` asserts these against them.
|
|
39
|
+
#
|
|
40
|
+
# The order is the order of usefulness: `skip_encoded` names the actual
|
|
41
|
+
# mistake, so it is reported ahead of the "nothing landed" symptom it causes.
|
|
42
|
+
# A middleware mounted behind compression matches both branches, and reporting
|
|
43
|
+
# the symptom would send somebody hunting through their manifest for a
|
|
44
|
+
# mounting bug.
|
|
45
|
+
module Degraded
|
|
46
|
+
def self.reason(counters, has_rules)
|
|
47
|
+
skip_encoded = count(counters, :skip_encoded)
|
|
48
|
+
if skip_encoded.positive?
|
|
49
|
+
return "#{skip_encoded} response(s) arrived already compressed and could not be " \
|
|
50
|
+
'rewritten - the middleware is mounted after compression, and needs to be ' \
|
|
51
|
+
'mounted before it'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
errors = count(counters, :error)
|
|
55
|
+
if errors.positive?
|
|
56
|
+
# Not gated on `has_rules`, and the fixture says so in its own row: that
|
|
57
|
+
# gate guards the third branch only. A rewrite that raised is read from
|
|
58
|
+
# what happened to a response; `has_rules` is read from the manifest,
|
|
59
|
+
# and the two are not the same claim.
|
|
60
|
+
return "#{errors} rewrite(s) raised and the original page was served unchanged"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
considered = count(counters, :considered)
|
|
64
|
+
if has_rules && considered.positive? && count(counters, :applied).zero?
|
|
65
|
+
# `applied == 0`, never `applied < considered`. A page whose rule matched
|
|
66
|
+
# and whose output was identical is a `pass` rather than a failure, and
|
|
67
|
+
# reporting 39 of 40 as degraded would flag every site holding a rule for
|
|
68
|
+
# a page that already carries the right title.
|
|
69
|
+
return "#{considered} response(s) matched a rule and none were rewritten"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
''
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# One counter, whether it is keyed by Symbol or by String.
|
|
76
|
+
#
|
|
77
|
+
# The `Store` keeps Symbols and the shared fixture carries Strings, because
|
|
78
|
+
# it arrives through `JSON.parse`. Converting inside the test would mean the
|
|
79
|
+
# suite asserting against a shape it made up rather than the one the fixture
|
|
80
|
+
# ships (§20.1 rule 1), and converting inside `Store` would leave this
|
|
81
|
+
# function with a second caller shape it had never been run against. One
|
|
82
|
+
# lookup that answers both, at the only place that reads a counter by name.
|
|
83
|
+
def self.count(counters, name)
|
|
84
|
+
value = counters[name]
|
|
85
|
+
value = counters[name.to_s] if value.nil?
|
|
86
|
+
value.to_i
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class Store
|
|
91
|
+
EMPTY = { 'version' => '', 'pages' => {} }.freeze
|
|
92
|
+
|
|
93
|
+
# What this process has actually done since it started.
|
|
94
|
+
#
|
|
95
|
+
# These exist because the worst failure on this route is invisible. Mounted
|
|
96
|
+
# behind compression, or mounted on a path the manifest never names, the
|
|
97
|
+
# middleware serves every page untouched and a status endpoint reporting
|
|
98
|
+
# only "42 rules, pushed 3 minutes ago" looks perfectly healthy. Answering
|
|
99
|
+
# that with README prose telling the customer to be careful pushes the
|
|
100
|
+
# detection onto them; a counter and a `degraded` flag let a monitor find it.
|
|
101
|
+
#
|
|
102
|
+
# Raised 19 Aug 2026 by a customer's developer reviewing the install docs,
|
|
103
|
+
# who was right: "they documented the silent-failure mode instead of fixing
|
|
104
|
+
# it."
|
|
105
|
+
#
|
|
106
|
+
# Ten keys: the eight every implementation reports, plus the two this
|
|
107
|
+
# runtime can actually reach.
|
|
108
|
+
#
|
|
109
|
+
# **The comment that used to sit here said these were "one shape of status
|
|
110
|
+
# JSON whichever package answers". They are not, and never were.** Node
|
|
111
|
+
# ships `unmatched` and cannot ship `skip_undecodable` —
|
|
112
|
+
# `Buffer.toString('utf8')` substitutes replacement characters rather than
|
|
113
|
+
# failing, so it has nothing to count; Python ships `skip_undecodable` and
|
|
114
|
+
# no `unmatched`; PHP ships `skip_undecodable` plus `armed` and
|
|
115
|
+
# `skip_streamed`, which only an `ob_start` attach can fail at. The honest
|
|
116
|
+
# rule, and the one PHP writes out at `middleware/php/src/Store.php:100-165`,
|
|
117
|
+
# is: **every key is present at zero rather than absent**, so no reader ever
|
|
118
|
+
# writes a branch for a key that appears only on the stacks that hit it.
|
|
119
|
+
#
|
|
120
|
+
# `unmatched` was the missing one here, and its absence was not cosmetic. It
|
|
121
|
+
# counts requests that reached this middleware on a path the manifest does
|
|
122
|
+
# not name, and it is the third value that makes `considered: 0` readable: a
|
|
123
|
+
# manifest whose keys match nothing looks exactly like an install nobody has
|
|
124
|
+
# sent a request to. `engine.fix.origin.degraded_reason`'s docstring names
|
|
125
|
+
# it as the honest fix for that hole, Node implemented it
|
|
126
|
+
# (`middleware/node/src/manifest.ts:60-89`), and an operator alerting on
|
|
127
|
+
# `counters.unmatched > 0 && counters.considered == 0` was evaluating
|
|
128
|
+
# `nil > 0` against a Ruby origin — so the alert never fired, for exactly the
|
|
129
|
+
# failure it was written for. Ported 2 Sep 2026, counter and semantics
|
|
130
|
+
# unchanged, rather than reworded here (§20.1 rule 2).
|
|
131
|
+
#
|
|
132
|
+
# Still outstanding, and named rather than hidden: Python and PHP do not
|
|
133
|
+
# have `unmatched` yet, and `Degraded.reason` — which is pinned by the
|
|
134
|
+
# shared fixture — still does not read it in any language. What reads it
|
|
135
|
+
# here is `reach_reason`, this package's own layer over the shared verdict.
|
|
136
|
+
NO_COUNTERS = {
|
|
137
|
+
considered: 0, applied: 0, passed: 0, skip_encoded: 0,
|
|
138
|
+
skip_large: 0, skip_undecodable: 0, error: 0, redirect: 0, status: 0,
|
|
139
|
+
unmatched: 0
|
|
140
|
+
}.freeze
|
|
141
|
+
|
|
142
|
+
# Header marker to counter name. `1` is the applied case; `redirect` and
|
|
143
|
+
# `status` are served straight from the manifest and never reach a rewrite.
|
|
144
|
+
COUNTER_KEYS = {
|
|
145
|
+
'1' => :applied, 'pass' => :passed, 'error' => :error,
|
|
146
|
+
'skip-encoded' => :skip_encoded, 'skip-large' => :skip_large,
|
|
147
|
+
'skip-encoding' => :skip_undecodable,
|
|
148
|
+
'redirect' => :redirect, 'status' => :status
|
|
149
|
+
}.freeze
|
|
150
|
+
|
|
151
|
+
# The markers that mean a rule matched and a rewrite decision was reached.
|
|
152
|
+
# `redirect` and `status` are excluded: they are answers, not rewrites, and
|
|
153
|
+
# counting them would make `applied == 0` look explicable on a site whose
|
|
154
|
+
# manifest happens to be all redirects.
|
|
155
|
+
CONSIDERED = %w[1 pass error skip-encoded skip-large skip-encoding].freeze
|
|
156
|
+
|
|
157
|
+
# How often the cache file is checked for somebody else's push, in seconds.
|
|
158
|
+
#
|
|
159
|
+
# **This is the fork problem, and Ruby has it worse than the other two
|
|
160
|
+
# packages.** Puma in cluster mode, Unicorn and Passenger all serve from
|
|
161
|
+
# forked workers, and a manifest pushed to `/__findxpand/manifest` arrives at
|
|
162
|
+
# exactly one of them: the others go on serving the previous rules until the
|
|
163
|
+
# next deploy. Nothing reports it, and it is worse than a plain staleness bug
|
|
164
|
+
# — `verify` re-fetches the page through the same load balancer, so a fix
|
|
165
|
+
# lands or fails to land depending on which worker answers, and the same
|
|
166
|
+
# change verifies on one request and not the next.
|
|
167
|
+
#
|
|
168
|
+
# The worker that took the push has already written the cache file, so every
|
|
169
|
+
# other worker can see the change with a `stat`. Throttled to once a second
|
|
170
|
+
# because it is on the read path of every request; the rest of the time this
|
|
171
|
+
# costs one clock reading and one float comparison.
|
|
172
|
+
#
|
|
173
|
+
# Two limits, stated rather than hidden: `mtime` has one-second granularity
|
|
174
|
+
# on some filesystems, so two pushes inside the same second whose payloads
|
|
175
|
+
# are also the same length would look identical and the second would be
|
|
176
|
+
# missed until the one after it; and a deployment with no shared cache file
|
|
177
|
+
# gets none of this, which is why `FINDXPAND_CACHE_FILE` is documented as
|
|
178
|
+
# required rather than optional on a forking server.
|
|
179
|
+
#
|
|
180
|
+
# **No outbound request is involved.** This reads a local file that this
|
|
181
|
+
# process's own sibling wrote. The gem never calls Findxpand.
|
|
182
|
+
RELOAD_INTERVAL_S = 1.0
|
|
183
|
+
|
|
184
|
+
# How long after boot the "never in the request path" verdict is withheld.
|
|
185
|
+
#
|
|
186
|
+
# A process that has served no page and answers `/status` is either
|
|
187
|
+
# misattached or thirty seconds old, and those are the same picture. Naming
|
|
188
|
+
# the mistake immediately would page somebody on every deploy of a
|
|
189
|
+
# low-traffic site; never naming it is the §20.1 rule 3 failure this whole
|
|
190
|
+
# counter exists to end. A minute is long enough that a running site has
|
|
191
|
+
# served something and short enough that a monitor notices within its first
|
|
192
|
+
# few polls.
|
|
193
|
+
NEVER_SEEN_AFTER_S = 60
|
|
194
|
+
|
|
195
|
+
# How many unmatched requests are evidence, rather than an accident of what
|
|
196
|
+
# happened to arrive first.
|
|
197
|
+
#
|
|
198
|
+
# `unmatched` moves on every request the manifest does not name - an image, a
|
|
199
|
+
# stylesheet, a favicon, an API call - so one of them is not evidence of
|
|
200
|
+
# anything, and a verdict that fired at the first would accuse a perfectly
|
|
201
|
+
# healthy site whose first request was its own logo. §9 rule 3: a threshold
|
|
202
|
+
# has to be defensible, and "one" is not.
|
|
203
|
+
#
|
|
204
|
+
# Twenty, held together with the same one-minute window as the verdict below,
|
|
205
|
+
# because that pair is the cheapest thing that cannot be reached by accident:
|
|
206
|
+
# a working site clears it the moment a single page matches a rule
|
|
207
|
+
# (`considered` becomes positive and the branch stops applying), and a
|
|
208
|
+
# middleware mounted where pages never go never clears it, because nothing
|
|
209
|
+
# there will ever match. The review case this exists for reported
|
|
210
|
+
# `requests_seen: 847, considered: 0` - three floors above this one.
|
|
211
|
+
#
|
|
212
|
+
# Node counts the same thing and deliberately does not vote on it yet
|
|
213
|
+
# (`middleware/node/src/manifest.ts:60-89`: "Reported, so an operator and a
|
|
214
|
+
# monitor can see it; not yet voted on"), because `degradedReason` is pinned
|
|
215
|
+
# by the shared fixture and a branch only one package has is drift by
|
|
216
|
+
# construction. This vote is cast in `reach_reason`, which is this package's
|
|
217
|
+
# own layer and is not pinned by the fixture - the same place PHP casts its
|
|
218
|
+
# equivalent.
|
|
219
|
+
KEYS_MATCH_NOTHING_AFTER = 20
|
|
220
|
+
|
|
221
|
+
# The verdicts this package adds after the shared three, and why they are
|
|
222
|
+
# not *in* the shared three.
|
|
223
|
+
#
|
|
224
|
+
# `Degraded.reason` is pinned by `middleware/conformance/cases.json` and has
|
|
225
|
+
# to answer identically in four languages, so a branch only one of us has
|
|
226
|
+
# cannot live there. It lives in `reach_reason` instead, asked only when the
|
|
227
|
+
# shared verdict has nothing to say — the same layering PHP uses
|
|
228
|
+
# (`middleware/php/src/Store.php:580-633`, `reach_reason`, merged into
|
|
229
|
+
# `degraded_reason` only when that one is empty). This is not a second
|
|
230
|
+
# wording of one rule; it is a second rule, about whether the middleware is
|
|
231
|
+
# anywhere useful at all.
|
|
232
|
+
#
|
|
233
|
+
# Ruby's attach has more ways to silently not happen than Node's or
|
|
234
|
+
# Python's: `RUBYOPT` may not be set on the process that actually serves (a
|
|
235
|
+
# `bin/rails` wrapper, a Procfile line edited in one place of two), the
|
|
236
|
+
# Railtie only registers when the gem is required after Rails, a hand-written
|
|
237
|
+
# `use` can land inside a `map` block that pages never reach, and the server
|
|
238
|
+
# may parse `config.ru` with its own vendored builder (see `auto.rb`). Every
|
|
239
|
+
# one of those ends the same way — the admin endpoints answer, the manifest
|
|
240
|
+
# is present and current, and not one page has ever passed through us — and
|
|
241
|
+
# the shared verdicts cannot see it, because all three start from
|
|
242
|
+
# `considered`, which only a request that matched a rule ever moves.
|
|
243
|
+
#
|
|
244
|
+
# Each of these three reports something this process **observed**, never
|
|
245
|
+
# something it arranged (§20.1 rule 3). A push that was not written, requests
|
|
246
|
+
# that matched nothing, an uptime with no requests at all: all three are
|
|
247
|
+
# facts about what happened here, and all three are silent until there is
|
|
248
|
+
# one.
|
|
249
|
+
|
|
250
|
+
# A push arrived and could not be stored. Reported first, because it is the
|
|
251
|
+
# only one of the three that has already lost work: the engine reads the
|
|
252
|
+
# deployed manifest back before it merges the next fix, so a store that
|
|
253
|
+
# forgets is a store that discards every previously approved fix at the next
|
|
254
|
+
# restart, with no error anywhere. That was the shipped behaviour of every
|
|
255
|
+
# non-`RUBYOPT` install until 2 Sep 2026 — see `Findxpand.store`.
|
|
256
|
+
PUSH_NOT_PERSISTED =
|
|
257
|
+
'a manifest was pushed and could not be written to disk - this process serves it now, ' \
|
|
258
|
+
'a restart drops it, and on a forking server the other workers never saw it. Set ' \
|
|
259
|
+
'FINDXPAND_CACHE_FILE to a path every worker can write'
|
|
260
|
+
|
|
261
|
+
# Rules loaded, traffic arriving, and not one request has matched. The exact
|
|
262
|
+
# signature of the `normalise_path` disagreement that shipped, and of a
|
|
263
|
+
# middleware mounted inside a `map` block that pages never reach. `{count}`
|
|
264
|
+
# is `unmatched` rather than `requests_seen`, because the sentence is about
|
|
265
|
+
# requests that were *offered* to the manifest and refused by it.
|
|
266
|
+
KEYS_MATCH_NOTHING =
|
|
267
|
+
'%<count>d request(s) reached this middleware and not one matched a rule - the ' \
|
|
268
|
+
'manifest keys do not match the paths this site serves, or this middleware is ' \
|
|
269
|
+
'mounted where the pages do not go'
|
|
270
|
+
|
|
271
|
+
# Nothing has arrived at all, for longer than a boot takes.
|
|
272
|
+
NEVER_IN_REQUEST_PATH =
|
|
273
|
+
'the middleware is loaded and answering here, but no page request has ever reached ' \
|
|
274
|
+
'it - it is attached somewhere requests do not go'
|
|
275
|
+
|
|
276
|
+
def initialize(cache_file: nil, manifest_file: nil, clock: nil)
|
|
277
|
+
@cache_file = cache_file
|
|
278
|
+
@lock = Mutex.new
|
|
279
|
+
@current = EMPTY
|
|
280
|
+
@updated_at = nil
|
|
281
|
+
@source = 'empty'
|
|
282
|
+
@counters = NO_COUNTERS.dup
|
|
283
|
+
@last_applied_at = nil
|
|
284
|
+
# Every request that reached `Middleware#call` on a path that is not the
|
|
285
|
+
# admin surface, whether or not a rule matched. Counted separately from
|
|
286
|
+
# `considered` on purpose: `considered` answers "did a rule match", and
|
|
287
|
+
# the question here is the one before it, "was I in the path at all".
|
|
288
|
+
@requests = 0
|
|
289
|
+
# `nil` until a push has proved it one way or the other by being written
|
|
290
|
+
# or failing to be. Three states rather than two, and the third one is the
|
|
291
|
+
# point: an install that has been pushed nothing has not *failed* to
|
|
292
|
+
# persist, and reporting it as a fault would fire on every correct install
|
|
293
|
+
# on its first day. §20.1 rule 3 cuts both ways — nothing examined is not
|
|
294
|
+
# health, and it is not a failure either. Modelled on
|
|
295
|
+
# `middleware/php/src/Store.php`'s `$persisted`, which is `?bool` for the
|
|
296
|
+
# same reason.
|
|
297
|
+
@persisted = nil
|
|
298
|
+
# Injected so the time-dependent half of `status` is testable without
|
|
299
|
+
# sleeping. Every reading of the clock in this class goes through it.
|
|
300
|
+
@clock = clock || -> { Time.now.to_f }
|
|
301
|
+
@started_at = @clock.call
|
|
302
|
+
@checked_at = nil
|
|
303
|
+
@cache_fingerprint = nil
|
|
304
|
+
|
|
305
|
+
# A manifest committed to the repository wins at boot and is then
|
|
306
|
+
# overwritten by the first push. It is the mode for a client whose
|
|
307
|
+
# security review forbids an inbound endpoint — honestly the worse
|
|
308
|
+
# product, since it needs a deploy per change, but it must not be worse
|
|
309
|
+
# than nothing.
|
|
310
|
+
[[manifest_file, 'file'], [cache_file, 'push']].each do |path, source|
|
|
311
|
+
next if path.nil? || path.to_s.empty?
|
|
312
|
+
|
|
313
|
+
begin
|
|
314
|
+
loaded = JSON.parse(File.read(path, encoding: 'UTF-8'))
|
|
315
|
+
rescue SystemCallError, IOError, JSON::ParserError, ArgumentError
|
|
316
|
+
# A corrupt cache is not a reason to fail a boot. Serving the site
|
|
317
|
+
# unmodified is always safe; refusing to start never is. `ArgumentError`
|
|
318
|
+
# is in the list because `File.read` raises it, not a JSON error, when
|
|
319
|
+
# the file is not valid UTF-8.
|
|
320
|
+
next
|
|
321
|
+
end
|
|
322
|
+
next unless self.class.manifest?(loaded)
|
|
323
|
+
|
|
324
|
+
@current = loaded
|
|
325
|
+
@source = source
|
|
326
|
+
# **The file's own mtime, not `now`.** `age` is the field an operator
|
|
327
|
+
# reads to decide whether last night's push landed, and setting it to
|
|
328
|
+
# the moment this process *looked at* a file makes it answer "how long
|
|
329
|
+
# since I booted" — so a Rails app redeployed after four weeks with no
|
|
330
|
+
# push reports `age: 0` for rules that last changed 28 days ago, and
|
|
331
|
+
# somebody checking whether a push arrived concludes it did. Named and
|
|
332
|
+
# fixed first in `middleware/php/src/Store.php:294-304` ("Both shipped
|
|
333
|
+
# stores set `updated_at` to the moment they loaded the file"); Node and
|
|
334
|
+
# Python still have it. `nil` when the file's mtime cannot be read,
|
|
335
|
+
# which `status` already renders as `age: null` — unknown, rather than
|
|
336
|
+
# zero.
|
|
337
|
+
@updated_at = mtime_of(path)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# The boot read counts as a check: the file was just looked at, so
|
|
341
|
+
# `reload_if_stale` has nothing to learn from it for another interval.
|
|
342
|
+
# Without this the first request of every process re-reads and re-parses a
|
|
343
|
+
# file it has already read, and — worse for the suite — the throttle would
|
|
344
|
+
# be untestable, because its first call would always be the one that acts.
|
|
345
|
+
@checked_at = @started_at
|
|
346
|
+
@cache_fingerprint = fingerprint_of(@cache_file)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def self.manifest?(value)
|
|
350
|
+
value.is_a?(Hash) && value['version'].is_a?(String) && value['pages'].is_a?(Hash)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def get
|
|
354
|
+
@current
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def rule_for(url)
|
|
358
|
+
rule = @current['pages']
|
|
359
|
+
return nil unless rule.is_a?(Hash)
|
|
360
|
+
|
|
361
|
+
found = rule[Rewrite.normalise_path(url)]
|
|
362
|
+
found.is_a?(Hash) ? found : nil
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def redirect_for(url)
|
|
366
|
+
path = Rewrite.normalise_path(url)
|
|
367
|
+
list = @current['redirects']
|
|
368
|
+
return nil unless list.is_a?(Array)
|
|
369
|
+
|
|
370
|
+
list.find do |rule|
|
|
371
|
+
# Exact match only — no prefixes and no patterns. A rule that
|
|
372
|
+
# accidentally matched a subtree would send a whole section to one URL,
|
|
373
|
+
# and there is no undo for visitors who already followed it.
|
|
374
|
+
#
|
|
375
|
+
# **Both ends must be Strings, and `to` must not be empty.** This was
|
|
376
|
+
# `Rewrite.normalise_path(rule['from'].to_s)` alone, and the `.to_s` did
|
|
377
|
+
# two things silently: `{"from":null,"to":"/x"}` normalised to `"/"` and
|
|
378
|
+
# 301'd the *homepage*, and `{"from":"/old","to":null}` reached the
|
|
379
|
+
# middleware and was served as a 301 with an empty `location` — a dead
|
|
380
|
+
# page where the application would have rendered a working one, counted
|
|
381
|
+
# as a healthy `redirect`. Python raises on `str(redirect["to"])` and
|
|
382
|
+
# Node throws `ERR_HTTP_INVALID_HEADER_VALUE`; both fail loudly, and
|
|
383
|
+
# this failed silently in the one direction §3 rule 7 forbids. Matched
|
|
384
|
+
# to `middleware/php/src/Store.php:333-356`, which drops the entry and
|
|
385
|
+
# serves the page — the closed direction here is "serve the page
|
|
386
|
+
# unchanged", never "redirect to nowhere". Dropping the entry rather
|
|
387
|
+
# than the list is the same shape the engine's own fixture pins for a
|
|
388
|
+
# malformed `hreflang` row.
|
|
389
|
+
next false unless rule.is_a?(Hash)
|
|
390
|
+
|
|
391
|
+
from = rule['from']
|
|
392
|
+
to = rule['to']
|
|
393
|
+
next false unless from.is_a?(String) && to.is_a?(String) && !to.empty?
|
|
394
|
+
|
|
395
|
+
Rewrite.normalise_path(from) == path
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Store a pushed manifest and return its version. Raises on rubbish.
|
|
400
|
+
def replace(payload)
|
|
401
|
+
unless self.class.manifest?(payload)
|
|
402
|
+
raise ArgumentError, 'not a manifest: expected {version, pages}'
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
@lock.synchronize do
|
|
406
|
+
@current = payload
|
|
407
|
+
@updated_at = @clock.call
|
|
408
|
+
@source = 'push'
|
|
409
|
+
write_cache(payload)
|
|
410
|
+
end
|
|
411
|
+
payload['version'].to_s
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Count one response, by the marker it was served with.
|
|
415
|
+
#
|
|
416
|
+
# Takes the header value rather than a symbol so exactly one place decides
|
|
417
|
+
# what a response was: the middleware sets the header and passes the same
|
|
418
|
+
# string here. A second classification would eventually disagree with the
|
|
419
|
+
# header, and the header is what a customer greps for.
|
|
420
|
+
def record(marker)
|
|
421
|
+
@lock.synchronize do
|
|
422
|
+
@counters[:considered] += 1 if CONSIDERED.include?(marker)
|
|
423
|
+
key = COUNTER_KEYS[marker]
|
|
424
|
+
next if key.nil?
|
|
425
|
+
|
|
426
|
+
@counters[key] += 1
|
|
427
|
+
@last_applied_at = @clock.call if marker == '1'
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# One request arrived on a path that is not the admin surface.
|
|
432
|
+
#
|
|
433
|
+
# **Not called for `/__findxpand/status`**, and that is the whole point: a
|
|
434
|
+
# probe that satisfies its own question is an auditor that cannot fail
|
|
435
|
+
# (§20.1 rule 3). If asking whether we have ever been in the request path
|
|
436
|
+
# counted as having been in it, the answer would be yes from the first poll
|
|
437
|
+
# on a middleware mounted where no page ever goes.
|
|
438
|
+
#
|
|
439
|
+
# **It is every non-admin request, not every page request**, and the README
|
|
440
|
+
# said the latter until 2 Sep 2026. An asset, a favicon, an API call or a
|
|
441
|
+
# load-balancer probe moves this counter, so one `GET /api/health` clears
|
|
442
|
+
# `NEVER_IN_REQUEST_PATH` permanently on a middleware mounted inside a `map
|
|
443
|
+
# '/api'` block that no page will ever reach. That is why it is the weakest
|
|
444
|
+
# of the three reach verdicts and why `unmatched` exists: this one answers
|
|
445
|
+
# "was I in a request path", and `unmatched` answers "was I in the one the
|
|
446
|
+
# pages go down".
|
|
447
|
+
def saw_request
|
|
448
|
+
@lock.synchronize { @requests += 1 }
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# One request reached us on a path the manifest does not name.
|
|
452
|
+
#
|
|
453
|
+
# Separate from `record` rather than a marker string through it, because
|
|
454
|
+
# `record` takes the value of the header the response was served with and
|
|
455
|
+
# this response is served with no header at all — we hand the request to the
|
|
456
|
+
# application before it produces one. Passing a marker no header ever
|
|
457
|
+
# carries would break the one thing `record`'s doc promises: that a customer
|
|
458
|
+
# can grep the header value and find the counter it moved. Matched to
|
|
459
|
+
# `middleware/node/src/manifest.ts:274-284`, which says the same thing about
|
|
460
|
+
# the same counter.
|
|
461
|
+
def miss
|
|
462
|
+
@lock.synchronize { @counters[:unmatched] += 1 }
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Adopt a manifest another worker was pushed, if there is one.
|
|
466
|
+
#
|
|
467
|
+
# Called once per request from the middleware. See `RELOAD_INTERVAL_S` for
|
|
468
|
+
# why this exists at all; everything here is deliberately silent, because a
|
|
469
|
+
# cache file that has been deleted, is being written, or was written by a
|
|
470
|
+
# different version of this gem is not a reason to stop serving pages.
|
|
471
|
+
def reload_if_stale
|
|
472
|
+
return if @cache_file.nil? || @cache_file.to_s.empty?
|
|
473
|
+
|
|
474
|
+
now = @clock.call
|
|
475
|
+
return if !@checked_at.nil? && (now - @checked_at) < RELOAD_INTERVAL_S
|
|
476
|
+
|
|
477
|
+
@checked_at = now
|
|
478
|
+
fingerprint = fingerprint_of(@cache_file)
|
|
479
|
+
return if fingerprint.nil? || fingerprint == @cache_fingerprint
|
|
480
|
+
|
|
481
|
+
@cache_fingerprint = fingerprint
|
|
482
|
+
begin
|
|
483
|
+
loaded = JSON.parse(File.read(@cache_file.to_s, encoding: 'UTF-8'))
|
|
484
|
+
rescue SystemCallError, IOError, JSON::ParserError, ArgumentError
|
|
485
|
+
return
|
|
486
|
+
end
|
|
487
|
+
return unless self.class.manifest?(loaded)
|
|
488
|
+
# Our own write, or a re-read of what we already serve. `updated_at` is
|
|
489
|
+
# left alone in that case, so `age` keeps meaning "when did these rules
|
|
490
|
+
# change" rather than "when did we last look at a file".
|
|
491
|
+
return if loaded['version'] == @current['version']
|
|
492
|
+
|
|
493
|
+
@lock.synchronize do
|
|
494
|
+
@current = loaded
|
|
495
|
+
# The mtime we just stat'ed, for the same reason as at boot: this is
|
|
496
|
+
# when the *sibling* wrote these rules, and `now` is when this worker
|
|
497
|
+
# happened to notice. On a forking server every worker adopts at a
|
|
498
|
+
# different moment, so `now` would have four workers reporting four
|
|
499
|
+
# different ages for one push.
|
|
500
|
+
@updated_at = fingerprint[0]
|
|
501
|
+
@source = 'push'
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def status
|
|
506
|
+
now = @clock.call
|
|
507
|
+
pages = @current['pages'].is_a?(Hash) ? @current['pages'].size : 0
|
|
508
|
+
redirects = @current['redirects'].is_a?(Array) ? @current['redirects'].size : 0
|
|
509
|
+
counters = @counters.dup
|
|
510
|
+
has_rules = pages.positive? || redirects.positive?
|
|
511
|
+
# The shared verdict first, always, and this package's own only when that
|
|
512
|
+
# one has nothing to say. Same order as
|
|
513
|
+
# `middleware/php/src/Store.php:743-747`.
|
|
514
|
+
reason = Degraded.reason(counters, has_rules)
|
|
515
|
+
reach = reach_reason(counters, has_rules, now)
|
|
516
|
+
reason = reach if reason.empty?
|
|
517
|
+
|
|
518
|
+
{
|
|
519
|
+
'ok' => true,
|
|
520
|
+
'version' => @current['version'].to_s,
|
|
521
|
+
'pages' => pages,
|
|
522
|
+
'redirects' => redirects,
|
|
523
|
+
'age' => @updated_at.nil? ? nil : (now - @updated_at).round,
|
|
524
|
+
'source' => @source,
|
|
525
|
+
'counters' => counters.transform_keys(&:to_s),
|
|
526
|
+
'requests_seen' => @requests,
|
|
527
|
+
'uptime' => (now - @started_at).round,
|
|
528
|
+
'applied_age' => @last_applied_at.nil? ? nil : (now - @last_applied_at).round,
|
|
529
|
+
'degraded' => !reason.empty?,
|
|
530
|
+
'degraded_reason' => reason,
|
|
531
|
+
# `true` a push was stored, `false` a push was lost, `nil` nothing has
|
|
532
|
+
# been pushed here yet. Reported rather than folded into `degraded`
|
|
533
|
+
# alone, because "we cannot persist" and "we have not been asked to yet"
|
|
534
|
+
# are different answers and a monitor should be able to see which.
|
|
535
|
+
'persisted' => @persisted,
|
|
536
|
+
# The reach verdict on its own, whether or not it is the one `degraded`
|
|
537
|
+
# is reporting. Additive, like PHP's `reach_reason` key: without it, a
|
|
538
|
+
# process that is both mounted behind compression *and* unable to
|
|
539
|
+
# persist shows only the first, and the second disappears until the
|
|
540
|
+
# first is fixed.
|
|
541
|
+
'reach_reason' => reach,
|
|
542
|
+
'manifest' => @current
|
|
543
|
+
}
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# What only this package can answer, asked after the shared verdict.
|
|
547
|
+
#
|
|
548
|
+
# Every branch reports an observation. `@persisted == false` means a push was
|
|
549
|
+
# taken and a write was attempted or had nowhere to go; `unmatched` past
|
|
550
|
+
# `KEYS_MATCH_NOTHING_AFTER` with `considered` still at zero means requests
|
|
551
|
+
# arrived and the manifest refused all of them; `@requests.zero?` past
|
|
552
|
+
# `NEVER_SEEN_AFTER_S` means nothing has arrived at all for longer than a boot
|
|
553
|
+
# takes. None of them is inferred from configuration, and all of them are
|
|
554
|
+
# silent while there is nothing to report — `''` here means *not examined*,
|
|
555
|
+
# exactly as it does in the shared function.
|
|
556
|
+
#
|
|
557
|
+
# The two counting verdicts share the one-minute window, and that is
|
|
558
|
+
# deliberate: both of them are "this process has been running long enough
|
|
559
|
+
# that its silence means something", and a process thirty seconds into a
|
|
560
|
+
# deploy has not. Paging somebody on every deploy is how an alert stops being
|
|
561
|
+
# read.
|
|
562
|
+
def reach_reason(counters, has_rules, now)
|
|
563
|
+
return PUSH_NOT_PERSISTED if @persisted == false
|
|
564
|
+
|
|
565
|
+
settled = (now - @started_at) > NEVER_SEEN_AFTER_S
|
|
566
|
+
unmatched = Degraded.count(counters, :unmatched)
|
|
567
|
+
if has_rules && settled && Degraded.count(counters, :considered).zero? &&
|
|
568
|
+
unmatched >= KEYS_MATCH_NOTHING_AFTER
|
|
569
|
+
return format(KEYS_MATCH_NOTHING, count: unmatched)
|
|
570
|
+
end
|
|
571
|
+
return NEVER_IN_REQUEST_PATH if @requests.zero? && settled
|
|
572
|
+
|
|
573
|
+
''
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
private
|
|
577
|
+
|
|
578
|
+
# `[mtime, size]` for a file, or nil when there is not one to look at.
|
|
579
|
+
#
|
|
580
|
+
# Two signals rather than one because `mtime` has one-second granularity on
|
|
581
|
+
# some filesystems, and a size is free. Neither is a hash of the content:
|
|
582
|
+
# reading the file to decide whether to read the file is the loop this is
|
|
583
|
+
# here to avoid.
|
|
584
|
+
def fingerprint_of(path)
|
|
585
|
+
return nil if path.nil? || path.to_s.empty?
|
|
586
|
+
|
|
587
|
+
stat = File.stat(path.to_s)
|
|
588
|
+
[stat.mtime.to_f, stat.size]
|
|
589
|
+
rescue SystemCallError
|
|
590
|
+
nil
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# The file's mtime as a Float, or nil when there is not one to read.
|
|
594
|
+
def mtime_of(path)
|
|
595
|
+
stat = fingerprint_of(path)
|
|
596
|
+
stat.nil? ? nil : stat[0]
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def write_cache(payload)
|
|
600
|
+
if @cache_file.nil? || @cache_file.to_s.empty?
|
|
601
|
+
# Not silence. A push we cannot keep is a push that is lost at the next
|
|
602
|
+
# restart and that never reaches the other workers, and `/status` said
|
|
603
|
+
# nothing about it until 2 Sep 2026 — the §20.1 rule 3 failure at its
|
|
604
|
+
# most expensive, since the engine reads this manifest back before
|
|
605
|
+
# merging the next fix. Recorded here, reported by `reach_reason`.
|
|
606
|
+
@persisted = false
|
|
607
|
+
return
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
FileUtils.mkdir_p(File.dirname(@cache_file.to_s))
|
|
611
|
+
write_atomic(@cache_file.to_s, JSON.generate(payload))
|
|
612
|
+
# Our own write, recorded so `reload_if_stale` does not read it straight
|
|
613
|
+
# back on the next request and call it somebody else's push.
|
|
614
|
+
@cache_fingerprint = fingerprint_of(@cache_file)
|
|
615
|
+
@persisted = true
|
|
616
|
+
rescue SystemCallError, IOError
|
|
617
|
+
# Kept in memory regardless. An unwritable cache costs the rules on the
|
|
618
|
+
# next restart; refusing the push costs them now. Recorded as a failure to
|
|
619
|
+
# persist, so the loss is reported instead of being invisible.
|
|
620
|
+
@persisted = false
|
|
621
|
+
nil
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Write to a temporary file in the same directory, then rename onto it.
|
|
625
|
+
#
|
|
626
|
+
# `File.write` truncates and rewrites in place, and this is the only one of
|
|
627
|
+
# the four packages that reads its own cache file from *other processes* on
|
|
628
|
+
# the request path — `reload_if_stale`, up to once a second per worker. A
|
|
629
|
+
# sibling that stats the file inside the truncation window sees `[T, 0]`,
|
|
630
|
+
# stores that as its fingerprint, reads `""`, and defers adoption until the
|
|
631
|
+
# next stat differs from it; on a filesystem with coarse mtime granularity
|
|
632
|
+
# that is a wrong answer rather than a one-second delay. `rename` is atomic
|
|
633
|
+
# within a filesystem on both POSIX and Windows, so a reader sees either the
|
|
634
|
+
# old file or the new one and never a half-written one. The PHP package
|
|
635
|
+
# already does this and named it (`middleware/php/src/Store.php:1011-1032`);
|
|
636
|
+
# this is the same fix, not a second design (§20.1 rule 2).
|
|
637
|
+
#
|
|
638
|
+
# The pid is in the temporary name because the whole reason this file exists
|
|
639
|
+
# is that several forked workers share it, and two of them writing
|
|
640
|
+
# `manifest.json.tmp` at once would rename each other's half-written bytes
|
|
641
|
+
# into place. Within one process `replace` holds the lock, so there is no
|
|
642
|
+
# second writer to collide with.
|
|
643
|
+
def write_atomic(path, body)
|
|
644
|
+
temporary = "#{path}.#{Process.pid}.tmp"
|
|
645
|
+
begin
|
|
646
|
+
File.write(temporary, body, encoding: 'UTF-8')
|
|
647
|
+
File.rename(temporary, path)
|
|
648
|
+
ensure
|
|
649
|
+
begin
|
|
650
|
+
File.unlink(temporary) if File.exist?(temporary)
|
|
651
|
+
rescue SystemCallError
|
|
652
|
+
nil
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
end
|