railwatch 0.2.2 → 0.3.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 +4 -4
- data/CHANGELOG.md +30 -0
- data/app/models/railwatch/ingest/batch.rb +50 -1
- data/app/models/railwatch/telemetry/export_delivery.rb +32 -0
- data/app/models/railwatch/telemetry/export_destination.rb +66 -0
- data/db/railwatch_telemetry_migrate/20260919000000_create_export_queue.rb +108 -0
- data/docs/embedded.md +30 -0
- data/lib/railwatch/configuration.rb +61 -1
- data/lib/railwatch/engine.rb +11 -0
- data/lib/railwatch/export/client.rb +117 -0
- data/lib/railwatch/export/lease.rb +56 -0
- data/lib/railwatch/export/outbox.rb +361 -0
- data/lib/railwatch/export/policy.rb +79 -0
- data/lib/railwatch/export/sender.rb +130 -0
- data/lib/railwatch/maintenance.rb +11 -0
- data/lib/railwatch/reporter.rb +4 -1
- data/lib/railwatch/transport/http.rb +127 -53
- data/lib/railwatch/transport/wire_encoder.rb +60 -0
- data/lib/railwatch/version.rb +1 -1
- data/lib/railwatch.rb +7 -0
- data/lib/tasks/railwatch_tasks.rake +36 -0
- metadata +10 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Export
|
|
5
|
+
# The durable queue. Every state change a delivery can undergo lives here,
|
|
6
|
+
# and nothing here touches the network.
|
|
7
|
+
#
|
|
8
|
+
# Admission happens inside the caller's ingest transaction, so a batch and
|
|
9
|
+
# its intent to mirror commit together: there is no state where the rows
|
|
10
|
+
# are stored but the delivery was lost, nor one where a delivery exists
|
|
11
|
+
# for rows that rolled back. Everything after that runs in its own short
|
|
12
|
+
# transaction, because the alternative is holding SQLite's write lock
|
|
13
|
+
# across an HTTP request.
|
|
14
|
+
#
|
|
15
|
+
# Two rules the accounting depends on. A delivery leaves the live set by a
|
|
16
|
+
# conditional UPDATE, so two housekeepers racing the same row produce one
|
|
17
|
+
# transition, not two. And the destination row is read fresh inside every
|
|
18
|
+
# transaction that changes it -- a counter adjusted against a snapshot
|
|
19
|
+
# taken earlier is how a queue ends up charged for bodies it has freed,
|
|
20
|
+
# or crediting itself for deliveries it still holds.
|
|
21
|
+
class Outbox
|
|
22
|
+
# A claim handed to a sender: the bytes and the right to finish, with no
|
|
23
|
+
# open database connection attached.
|
|
24
|
+
# Carries the destination it is FOR. A sender reconfigured between the
|
|
25
|
+
# claim and the send must not post these bytes somewhere else.
|
|
26
|
+
Claim = Struct.new(:id, :delivery_id, :body, :record_count, :metadata, :token, :generation,
|
|
27
|
+
:url, :token_digest, :producer_id, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
Admission = Struct.new(:disposition, :record_count, keyword_init: true)
|
|
30
|
+
|
|
31
|
+
def initialize(config, environment)
|
|
32
|
+
@config = config
|
|
33
|
+
@environment = environment
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# MUST run inside the caller's telemetry transaction. Returns what to
|
|
37
|
+
# record on the batch's own ledger row, which is how an install can see
|
|
38
|
+
# why a batch mirrored nothing.
|
|
39
|
+
def enqueue!(selections, now: Time.current)
|
|
40
|
+
destination = binding_row
|
|
41
|
+
return Admission.new(disposition: "blocked_config", record_count: 0) unless destination
|
|
42
|
+
return shed(destination, 0, "empty") if selections.empty?
|
|
43
|
+
# A deferred destination still queues -- waiting is what the queue is
|
|
44
|
+
# for. A blocked one does not: those deliveries could only ever be
|
|
45
|
+
# discarded, and they would take capacity from telemetry that can
|
|
46
|
+
# still be sent once somebody fixes it.
|
|
47
|
+
if destination.blocked?
|
|
48
|
+
return shed(destination, selections.sum { |s| s.record_count.to_i }, "blocked_destination")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
admitted = 0
|
|
52
|
+
# A selection with no body is the policy telling us everything it was
|
|
53
|
+
# given was too large to send.
|
|
54
|
+
if (unsendable = selections.reject(&:key)).any?
|
|
55
|
+
return shed(destination, unsendable.sum { |s| s.dropped.to_i }, "shed_oversize")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
selections.each do |selection|
|
|
59
|
+
case admit(destination, selection, now)
|
|
60
|
+
in :admitted then admitted += selection.record_count
|
|
61
|
+
in :duplicate then next
|
|
62
|
+
in :capacity
|
|
63
|
+
# Only what this selection would have been: earlier ones in the
|
|
64
|
+
# same call are already admitted and are not lost.
|
|
65
|
+
return shed(destination, selection.record_count, "shed_capacity")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
Admission.new(disposition: "queued", record_count: admitted)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def status(now: Time.current)
|
|
72
|
+
destination = binding_row or return { enabled: false, reason: @config.export_problem }
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
enabled: true, producer_id: destination.producer_id, url: destination.url,
|
|
76
|
+
state: destination.state, reason: destination.reason,
|
|
77
|
+
queued_deliveries: destination.queued_deliveries, queued_bytes: destination.queued_bytes,
|
|
78
|
+
oldest_queued_at: destination.export_deliveries.live.oldest_first.pick(:enqueued_at),
|
|
79
|
+
sendable: destination.sendable?(now: now), counters: destination.counters
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Takes the lease and the oldest delivery that is due, or returns nil.
|
|
84
|
+
# One in flight at a time: a queue draining an outage should do it in
|
|
85
|
+
# order, not open a connection per row.
|
|
86
|
+
def claim!(owner:, now: Time.current)
|
|
87
|
+
destination = binding_row
|
|
88
|
+
return nil unless destination&.sendable?(now: now)
|
|
89
|
+
|
|
90
|
+
Telemetry::ExportDelivery.transaction do
|
|
91
|
+
generation = Lease.acquire(destination.id, owner: owner, now: now) or next nil
|
|
92
|
+
|
|
93
|
+
reclaim_abandoned(destination, now)
|
|
94
|
+
# Not merely due: still worth sending. Expiry is swept every few
|
|
95
|
+
# minutes, and a restart after a long outage must not post work from
|
|
96
|
+
# before the receiver would still recognise it.
|
|
97
|
+
delivery = destination.export_deliveries.due(now).where(expires_at: now..)
|
|
98
|
+
.oldest_first.lock.first or next nil
|
|
99
|
+
|
|
100
|
+
token = SecureRandom.uuid
|
|
101
|
+
delivery.update!(state: "sending", claim_token: token, claim_generation: generation,
|
|
102
|
+
claim_expires_at: now + Lease::TTL, attempts: delivery.attempts + 1)
|
|
103
|
+
Claim.new(id: delivery.id, delivery_id: delivery.delivery_id, body: delivery.body,
|
|
104
|
+
record_count: delivery.record_count, metadata: delivery.wire_metadata,
|
|
105
|
+
token: token, generation: generation, url: destination.url,
|
|
106
|
+
token_digest: destination.credential_sha256, producer_id: destination.producer_id)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Given up voluntarily, so the next process does not wait out the TTL.
|
|
111
|
+
# Only ever our own: the generation check means a lease we already lost
|
|
112
|
+
# is not ours to release.
|
|
113
|
+
def release_lease!(owner:, now: Time.current)
|
|
114
|
+
destination = binding_row or return false
|
|
115
|
+
return false unless destination.lease_owner == owner
|
|
116
|
+
return false if destination.export_deliveries.exists?(state: "sending")
|
|
117
|
+
|
|
118
|
+
Lease.release(destination.id, owner: owner, generation: destination.lease_generation, now: now)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def renew!(claim, owner:, now: Time.current)
|
|
122
|
+
destination = binding_row or return false
|
|
123
|
+
return false unless Lease.renew(destination.id, owner: owner, generation: claim.generation, now: now)
|
|
124
|
+
|
|
125
|
+
Telemetry::ExportDelivery.where(id: claim.id, claim_token: claim.token)
|
|
126
|
+
.update_all([ "claim_expires_at = ?, updated_at = ?", now + Lease::TTL, now ]) == 1
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Records what the receiver said. Returns false when this claim is no
|
|
130
|
+
# longer the one allowed to speak for the delivery -- a stale holder
|
|
131
|
+
# whose request landed anyway must not overwrite the new holder's work.
|
|
132
|
+
def finish!(claim, outcome, now: Time.current)
|
|
133
|
+
Telemetry::ExportDelivery.transaction do
|
|
134
|
+
delivery = Telemetry::ExportDelivery.lock.find_by(id: claim.id)
|
|
135
|
+
next false unless delivery&.held_by?(claim.token, claim.generation)
|
|
136
|
+
|
|
137
|
+
case outcome.disposition
|
|
138
|
+
when :stored, :acked
|
|
139
|
+
clear_pause(delivery.export_destination_id, now)
|
|
140
|
+
finish(claim.id, "acked", now: now, status: outcome.status, ack: outcome.ack)
|
|
141
|
+
when :rejected then finish(claim.id, "rejected", now: now, status: outcome.status, reason: outcome.reason)
|
|
142
|
+
else defer(delivery, outcome, now)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Terminalises whatever has run out of time. A delivery already on the
|
|
148
|
+
# network may still commit at the receiver; expiry means we have stopped
|
|
149
|
+
# waiting for it, not that it did not arrive.
|
|
150
|
+
def expire!(now: Time.current, limit: 200)
|
|
151
|
+
destination = binding_row or return 0
|
|
152
|
+
|
|
153
|
+
ids = destination.export_deliveries.overdue(now).limit(limit).pluck(:id)
|
|
154
|
+
ids.count { |id| finish(id, "expired", now: now) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Forgets terminal rows once they are only history. Their bodies are
|
|
158
|
+
# already gone; this is the metadata.
|
|
159
|
+
def prune!(now: Time.current, keep_for: 8 * 24 * 60 * 60, limit: 200)
|
|
160
|
+
destination = binding_row or return 0
|
|
161
|
+
|
|
162
|
+
ids = destination.export_deliveries.where(state: "done")
|
|
163
|
+
.where(finished_at: ...(now - keep_for)).limit(limit).pluck(:id)
|
|
164
|
+
return 0 if ids.empty?
|
|
165
|
+
|
|
166
|
+
Telemetry::ExportDelivery.where(id: ids).delete_all
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Abandons everything queued, without contacting anyone. What has
|
|
170
|
+
# already been sent cannot be recalled; this stops what has not.
|
|
171
|
+
def discard_all!(now: Time.current)
|
|
172
|
+
destination = binding_row or return 0
|
|
173
|
+
|
|
174
|
+
destination.export_deliveries.live.pluck(:id).count { |id| finish(id, "discarded", now: now) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Clears a credential block, after abandoning the work that was admitted
|
|
178
|
+
# under the old token. Those bytes were promised to whoever that token
|
|
179
|
+
# named; they are not this destination's to deliver now.
|
|
180
|
+
def rebind!(now: Time.current)
|
|
181
|
+
destination = binding_row or return false
|
|
182
|
+
|
|
183
|
+
discarded = discard_all!(now: now)
|
|
184
|
+
Telemetry::ExportDestination.where(id: destination.id)
|
|
185
|
+
.update_all(state: "ready", reason: nil, retry_at: nil, updated_at: now)
|
|
186
|
+
discarded
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Recomputes the counters from the rows themselves. The incremental ones
|
|
190
|
+
# are correct by construction, but a queue that has been through a
|
|
191
|
+
# crash mid-transaction deserves a way to prove it.
|
|
192
|
+
def recount!
|
|
193
|
+
destination = binding_row or return nil
|
|
194
|
+
|
|
195
|
+
live = destination.export_deliveries.live
|
|
196
|
+
Telemetry::ExportDestination.where(id: destination.id).update_all(
|
|
197
|
+
queued_deliveries: live.count, queued_bytes: live.sum(:body_bytes), updated_at: Time.current
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Deliberately not memoised: a counter adjusted against a stale snapshot
|
|
202
|
+
# is the whole bug class this queue has to avoid.
|
|
203
|
+
def binding_row
|
|
204
|
+
return nil unless @config.export?
|
|
205
|
+
|
|
206
|
+
Telemetry::ExportDestination.bind!(url: @config.resolved_export_url,
|
|
207
|
+
token: @config.resolved_export_token)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
# A holder that vanished leaves its delivery claimed. Once the claim has
|
|
213
|
+
# expired the row goes back in the queue; the fence stops the vanished
|
|
214
|
+
# holder from finishing it later.
|
|
215
|
+
def reclaim_abandoned(destination, now)
|
|
216
|
+
destination.export_deliveries.where(state: "sending")
|
|
217
|
+
.where(claim_expires_at: ...now)
|
|
218
|
+
.update_all([ "state = 'pending', claim_token = NULL, claim_generation = NULL, claim_expires_at = NULL, updated_at = ?", now ])
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Not stored, and worth trying again. The delay is ours unless the
|
|
222
|
+
# receiver named a later one -- we never come back sooner than it asked.
|
|
223
|
+
def defer(delivery, outcome, now)
|
|
224
|
+
wait = backoff(delivery.attempts)
|
|
225
|
+
at = [ now + wait, outcome.retry_after_at ].compact.max
|
|
226
|
+
delivery.update!(state: "pending", claim_token: nil, claim_generation: nil, claim_expires_at: nil,
|
|
227
|
+
next_attempt_at: at, last_status: outcome.status, last_reason: outcome.reason)
|
|
228
|
+
pause_destination(delivery.export_destination_id, outcome, at, now)
|
|
229
|
+
true
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Only for answers that are about the destination rather than this
|
|
233
|
+
# delivery. A refused token, an exhausted quota, an explicit "slow
|
|
234
|
+
# down" -- those apply to everything queued. A 500 or a timeout is one
|
|
235
|
+
# delivery having a bad time, and pausing the queue for it would turn a
|
|
236
|
+
# blip into an outage.
|
|
237
|
+
# 503 is deliberately absent. A receiver briefly unavailable is this
|
|
238
|
+
# delivery's bad luck; pausing the queue for it would hold up every
|
|
239
|
+
# other delivery behind one unlucky request. 429 and 402 are the
|
|
240
|
+
# receiver telling us something about itself.
|
|
241
|
+
DESTINATION_WIDE = { 401 => "unauthorized", 403 => "unauthorized",
|
|
242
|
+
402 => "deferred", 429 => "deferred" }.freeze
|
|
243
|
+
|
|
244
|
+
# It is taking deliveries again, so stop saying it is not. A credential
|
|
245
|
+
# block is left alone: that one is not ours to decide has passed.
|
|
246
|
+
def clear_pause(destination_id, now)
|
|
247
|
+
Telemetry::ExportDestination.where(id: destination_id, state: "deferred")
|
|
248
|
+
.update_all([ "state = 'ready', reason = NULL, retry_at = NULL, updated_at = ?", now ])
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def pause_destination(destination_id, outcome, at, now)
|
|
252
|
+
state = DESTINATION_WIDE[outcome.status] or return
|
|
253
|
+
|
|
254
|
+
Telemetry::ExportDestination.where(id: destination_id).update_all([
|
|
255
|
+
"state = ?, reason = ?, retry_at = ?, updated_at = ?",
|
|
256
|
+
state, (outcome.reason || state).to_s[0, 64], at, now
|
|
257
|
+
])
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
BACKOFF_CEILING = 60
|
|
261
|
+
|
|
262
|
+
# Exponential with jitter, so a fleet that lost the receiver together
|
|
263
|
+
# does not come back in lockstep. There is no attempt limit: expiry is
|
|
264
|
+
# the limit, and it is measured in time rather than tries.
|
|
265
|
+
def backoff(attempts)
|
|
266
|
+
ceiling = [ 2**[ attempts - 1, 6 ].min, BACKOFF_CEILING ].min
|
|
267
|
+
ceiling * (0.5 + (SecureRandom.random_number / 2))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def admit(destination, selection, now)
|
|
271
|
+
# Before the capacity test, not after: work we already hold is not
|
|
272
|
+
# work we are about to lose, and a replay arriving at a full queue
|
|
273
|
+
# would otherwise be counted as a fresh loss every time it retried.
|
|
274
|
+
return :duplicate if destination.export_deliveries.exists?(selection_key: selection.key)
|
|
275
|
+
return :capacity unless room_for?(destination, selection)
|
|
276
|
+
|
|
277
|
+
Telemetry::ExportDelivery.create!(
|
|
278
|
+
export_destination: destination, delivery_id: delivery_id(now), selection_key: selection.key,
|
|
279
|
+
body: selection.body, body_sha256: selection.body_sha256,
|
|
280
|
+
metadata_sha256: selection.metadata_sha256, body_bytes: selection.body.bytesize,
|
|
281
|
+
ndjson_bytes: selection.ndjson_bytes, record_count: selection.record_count,
|
|
282
|
+
wire_metadata: selection.metadata, enqueued_at: now,
|
|
283
|
+
expires_at: now + @config.export_max_age, next_attempt_at: now
|
|
284
|
+
)
|
|
285
|
+
charge(destination.id, bytes: selection.body.bytesize, deliveries: 1)
|
|
286
|
+
:admitted
|
|
287
|
+
rescue ActiveRecord::RecordNotUnique
|
|
288
|
+
# The same selection already queued: a batch replayed after a crash,
|
|
289
|
+
# arriving at a queue that already took it. Nothing new was lost.
|
|
290
|
+
:duplicate
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Capacity is read from the row as it stands, not from whatever it said
|
|
294
|
+
# when this outbox was built.
|
|
295
|
+
def room_for?(destination, selection)
|
|
296
|
+
destination.queued_bytes + selection.body.bytesize <= @config.export_max_bytes &&
|
|
297
|
+
destination.queued_deliveries < @config.export_max_deliveries
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def charge(destination_id, bytes:, deliveries:)
|
|
301
|
+
Telemetry::ExportDestination.where(id: destination_id).update_all([
|
|
302
|
+
"queued_bytes = queued_bytes + ?, queued_deliveries = queued_deliveries + ?, updated_at = ?",
|
|
303
|
+
bytes, deliveries, Time.current
|
|
304
|
+
])
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# At capacity the new work is refused, not swapped for old work:
|
|
308
|
+
# evicting an already-admitted delivery would lose telemetry we have
|
|
309
|
+
# promised to send in favour of telemetry we have not.
|
|
310
|
+
def shed(destination, records, disposition)
|
|
311
|
+
bump(destination, "shed", records) if records.positive?
|
|
312
|
+
Admission.new(disposition: disposition, record_count: 0)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# One transaction, and a conditional transition inside it. Two
|
|
316
|
+
# housekeepers reaching the same row produce one terminal delivery and
|
|
317
|
+
# one release of its capacity.
|
|
318
|
+
def finish(id, disposition, now:, status: nil, reason: nil, ack: nil)
|
|
319
|
+
Telemetry::ExportDelivery.transaction do
|
|
320
|
+
delivery = Telemetry::ExportDelivery.lock.find_by(id: id)
|
|
321
|
+
next false unless delivery&.live?
|
|
322
|
+
|
|
323
|
+
bytes = delivery.body_bytes
|
|
324
|
+
delivery.update!(state: "done", disposition: disposition, finished_at: now, body: nil,
|
|
325
|
+
claim_token: nil, claim_generation: nil, claim_expires_at: nil,
|
|
326
|
+
last_status: status || delivery.last_status,
|
|
327
|
+
last_reason: reason || delivery.last_reason, ack: ack || delivery.ack)
|
|
328
|
+
release(delivery.export_destination_id, bytes)
|
|
329
|
+
bump(Telemetry::ExportDestination.find(delivery.export_destination_id), disposition, 1)
|
|
330
|
+
true
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Clamped at zero: a counter that has already been repaired must not be
|
|
335
|
+
# driven negative by a release that arrives afterwards.
|
|
336
|
+
def release(destination_id, bytes)
|
|
337
|
+
Telemetry::ExportDestination.where(id: destination_id).update_all([
|
|
338
|
+
"queued_bytes = MAX(queued_bytes - ?, 0), queued_deliveries = MAX(queued_deliveries - 1, 0), updated_at = ?",
|
|
339
|
+
bytes, Time.current
|
|
340
|
+
])
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def bump(destination, counter, by)
|
|
344
|
+
destination.bump!(counter, by)
|
|
345
|
+
Telemetry::ExportDestination.where(id: destination.id)
|
|
346
|
+
.update_all(counters: destination.counters, updated_at: Time.current)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# UUIDv7: time-ordered, and it carries its own creation time so the
|
|
350
|
+
# receiver can age it out without trusting a header.
|
|
351
|
+
def delivery_id(now)
|
|
352
|
+
SecureRandom.uuid_v7(extra_timestamp_bits: 0)
|
|
353
|
+
rescue ArgumentError, NoMethodError
|
|
354
|
+
ms = [ (now.to_f * 1000).to_i, 0 ].max & ((1 << 48) - 1)
|
|
355
|
+
hex = format("%012x", ms) + "7" + SecureRandom.hex(2)[0, 3] +
|
|
356
|
+
(8 + SecureRandom.random_number(4)).to_s(16) + SecureRandom.hex(8)[0, 15]
|
|
357
|
+
[ hex[0, 8], hex[8, 4], hex[12, 4], hex[16, 4], hex[20, 12] ].join("-")
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Railwatch
|
|
7
|
+
module Export
|
|
8
|
+
# What of a batch gets mirrored, and how it is packaged.
|
|
9
|
+
#
|
|
10
|
+
# There is one policy today and it sends everything: the same records, in
|
|
11
|
+
# the same order, that the very same install would have sent had it been
|
|
12
|
+
# configured for the cloud instead. That is deliberate. It means the
|
|
13
|
+
# receiver's charts, thresholds and issue counts are exactly as correct as
|
|
14
|
+
# they are for a cloud-only customer, and it means enabling mirroring
|
|
15
|
+
# discloses nothing that choosing the cloud would not have.
|
|
16
|
+
#
|
|
17
|
+
# A future selective policy returns fewer, smaller selections from the same
|
|
18
|
+
# method; nothing else in the queue needs to know.
|
|
19
|
+
module Policy
|
|
20
|
+
class Unsupported < StandardError; end
|
|
21
|
+
|
|
22
|
+
# One thing to send: immutable once built.
|
|
23
|
+
Selection = Struct.new(:key, :body, :body_sha256, :metadata, :record_count, :ndjson_bytes,
|
|
24
|
+
:dropped, keyword_init: true) do
|
|
25
|
+
def metadata_sha256
|
|
26
|
+
@metadata_sha256 ||= Digest::SHA256.hexdigest(JSON.generate(metadata.sort.to_h))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def fetch(name)
|
|
33
|
+
raise Unsupported, "unknown export policy #{name}" unless name.to_s == "everything"
|
|
34
|
+
|
|
35
|
+
Everything
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module Everything
|
|
39
|
+
VERSION = "everything-v1"
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# `records` is the batch as it arrived, before mapping: mapping is
|
|
44
|
+
# lossy and drops records this receiver may accept, so mirroring what
|
|
45
|
+
# we stored would not be mirroring what we were sent.
|
|
46
|
+
# The receiver refuses a request carrying more than this, whole. A
|
|
47
|
+
# delivery it will always reject is not worth storing: leave the
|
|
48
|
+
# excess out here, where it is counted as dropped, rather than
|
|
49
|
+
# discovering it after a round trip that destroys the lot.
|
|
50
|
+
MAX_RECORDS = 20_000
|
|
51
|
+
|
|
52
|
+
def prepare(records:, encoder:, source_batch_id:, metadata: {})
|
|
53
|
+
return [] if records.empty?
|
|
54
|
+
|
|
55
|
+
over_count = [ records.size - MAX_RECORDS, 0 ].max
|
|
56
|
+
encoded = encoder.encode(records.first(MAX_RECORDS))
|
|
57
|
+
# Records the encoder left out are lost to the receiver as surely as
|
|
58
|
+
# ones the client dropped, and the existing HTTP path reports them
|
|
59
|
+
# together. Adding, not replacing: a batch that dropped 7 and
|
|
60
|
+
# overflowed 2 lost 9.
|
|
61
|
+
dropped = metadata.fetch("dropped", 0).to_i + encoded.over_cap + over_count
|
|
62
|
+
dropped_bytes = metadata.fetch("dropped_bytes", 0).to_i + encoded.over_cap_bytes
|
|
63
|
+
wire = metadata.merge("policy" => VERSION, "version" => Railwatch::VERSION,
|
|
64
|
+
"dropped" => dropped, "dropped_bytes" => dropped_bytes)
|
|
65
|
+
# Nothing fitted. There is no body to send, but the loss is real and
|
|
66
|
+
# has to be reported rather than filed as "nothing to do".
|
|
67
|
+
return [ Selection.new(key: nil, record_count: 0, dropped: encoded.over_cap, metadata: wire) ] if encoded.sent.zero?
|
|
68
|
+
|
|
69
|
+
[ Selection.new(
|
|
70
|
+
key: "batch:#{source_batch_id}:#{VERSION}",
|
|
71
|
+
body: encoded.body, body_sha256: encoded.sha256,
|
|
72
|
+
record_count: encoded.sent, ndjson_bytes: encoded.uncompressed_bytes,
|
|
73
|
+
dropped: encoded.over_cap, metadata: wire
|
|
74
|
+
) ]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Export
|
|
5
|
+
# One thread per process, draining the queue to the receiver.
|
|
6
|
+
#
|
|
7
|
+
# Every eligible process runs one; the lease decides which actually sends,
|
|
8
|
+
# so there is no separate supervisor to keep alive and no dependence on
|
|
9
|
+
# the writer being up. A process that loses the lease keeps polling and
|
|
10
|
+
# takes over within its TTL if the holder disappears.
|
|
11
|
+
#
|
|
12
|
+
# Nothing here holds a database connection across an HTTP request: the
|
|
13
|
+
# claim commits, the request happens, the outcome commits. That is the
|
|
14
|
+
# whole reason the queue exists rather than sending inline.
|
|
15
|
+
module Sender
|
|
16
|
+
ROLES = %w[web worker writer].freeze
|
|
17
|
+
IDLE = 5
|
|
18
|
+
# An application process waits before its first attempt so the writer,
|
|
19
|
+
# which is the natural holder, gets the lease in the ordinary case.
|
|
20
|
+
FIRST_ATTEMPT_DELAY = 5
|
|
21
|
+
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@wakeup = ConditionVariable.new
|
|
24
|
+
@thread = nil
|
|
25
|
+
@pid = nil
|
|
26
|
+
@stopping = false
|
|
27
|
+
@owner = nil
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def start!
|
|
32
|
+
return unless Railwatch.enabled? && Railwatch.config.export?
|
|
33
|
+
return if defined?(Rails) && Rails.env.test?
|
|
34
|
+
return unless ROLES.include?(Subscribers::ProcessInfo.role)
|
|
35
|
+
return if @thread&.alive? && @pid == Process.pid
|
|
36
|
+
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
return if @thread&.alive? && @pid == Process.pid
|
|
39
|
+
|
|
40
|
+
@pid = Process.pid
|
|
41
|
+
@stopping = false
|
|
42
|
+
# Per process, not per database: it is this process's claim on the
|
|
43
|
+
# lease, and a forked child must never inherit its parent's.
|
|
44
|
+
@owner = SecureRandom.uuid
|
|
45
|
+
@thread = Thread.new { run }
|
|
46
|
+
@thread.name = "railwatch-export"
|
|
47
|
+
@thread.abort_on_exception = false
|
|
48
|
+
@thread.report_on_exception = false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A forked child inherits a dead thread and possibly a mutex held by a
|
|
53
|
+
# vanished one. It must not inherit the parent's lease claim either: the
|
|
54
|
+
# parent may still be sending under it.
|
|
55
|
+
def restart_after_fork!
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
@wakeup = ConditionVariable.new
|
|
58
|
+
@thread = nil
|
|
59
|
+
@pid = nil
|
|
60
|
+
@stopping = false
|
|
61
|
+
@owner = nil
|
|
62
|
+
@client = nil
|
|
63
|
+
start!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def stop!
|
|
67
|
+
return unless @thread
|
|
68
|
+
|
|
69
|
+
@stopping = true
|
|
70
|
+
@mutex.synchronize { @wakeup.signal }
|
|
71
|
+
# Only forget the thread if it actually stopped. Dropping the handle
|
|
72
|
+
# on a thread still inside a request would let a later start! run a
|
|
73
|
+
# second loop under the same owner, both claiming rows.
|
|
74
|
+
@thread = nil if @thread.join(Railwatch.config.shutdown_timeout)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Something was queued; look now rather than at the next tick.
|
|
78
|
+
def wake!
|
|
79
|
+
@mutex.synchronize { @wakeup.signal }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def run
|
|
83
|
+
wait(FIRST_ATTEMPT_DELAY)
|
|
84
|
+
until @stopping
|
|
85
|
+
sent = drain_one
|
|
86
|
+
wait(IDLE) unless sent || @stopping
|
|
87
|
+
end
|
|
88
|
+
release_lease
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Hand the lease back rather than making the next process wait out its
|
|
92
|
+
# TTL for a holder that has politely finished.
|
|
93
|
+
def release_lease
|
|
94
|
+
Railwatch.ignore do
|
|
95
|
+
environment = Environment.current
|
|
96
|
+
outbox = Outbox.new(Railwatch.config, environment)
|
|
97
|
+
environment.with_telemetry { outbox.release_lease!(owner: @owner) }
|
|
98
|
+
end
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
Railwatch.debug { "export sender: releasing lease failed: #{e.class}" }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def wait(seconds)
|
|
104
|
+
@mutex.synchronize { @wakeup.wait(@mutex, seconds) unless @stopping }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# One delivery, start to finish. Returns true when there may be more.
|
|
108
|
+
def drain_one
|
|
109
|
+
Railwatch.ignore do
|
|
110
|
+
environment = Environment.current
|
|
111
|
+
outbox = Outbox.new(Railwatch.config, environment)
|
|
112
|
+
claim = environment.with_telemetry { outbox.claim!(owner: @owner) } or return false
|
|
113
|
+
|
|
114
|
+
outcome = client.deliver(claim, producer_id: claim.producer_id)
|
|
115
|
+
environment.with_telemetry { outbox.finish!(claim, outcome) }
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
# The queue is durable: whatever went wrong here, the delivery is
|
|
120
|
+
# still there and its claim expires. Never take the thread down.
|
|
121
|
+
Railwatch.debug { "export sender: #{e.class}: #{e.message}" }
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def client
|
|
126
|
+
@client ||= Client.new(Railwatch.config)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -66,6 +66,17 @@ module Railwatch
|
|
|
66
66
|
"auto_resolve" => [ 24.hours, 10.minutes, lambda { |_env|
|
|
67
67
|
AutoResolveIssuesJob.new.perform
|
|
68
68
|
} ],
|
|
69
|
+
# Deliveries the export queue has stopped waiting for, and the history
|
|
70
|
+
# of ones it finished. Without this the age limit is advice rather than
|
|
71
|
+
# a limit: a delivery whose receiver never recovers keeps its bytes,
|
|
72
|
+
# and the queue eventually fills and sheds live telemetry instead.
|
|
73
|
+
"export_expiry" => [ 5.minutes, 10.minutes, lambda { |env|
|
|
74
|
+
next unless Railwatch.config.export?
|
|
75
|
+
|
|
76
|
+
outbox = Export::Outbox.new(Railwatch.config, env)
|
|
77
|
+
outbox.expire!
|
|
78
|
+
outbox.prune!
|
|
79
|
+
} ],
|
|
69
80
|
# PASSIVE, not TRUNCATE: this runs inside a Puma worker, and a
|
|
70
81
|
# truncating checkpoint blocks every reader and writer on the file.
|
|
71
82
|
"prune" => [ 24.hours, 60.minutes, lambda { |env|
|
data/lib/railwatch/reporter.rb
CHANGED
|
@@ -297,7 +297,10 @@ module Railwatch
|
|
|
297
297
|
# record kind, a record the environment does not retain). Cloud's
|
|
298
298
|
# ingest batch is the authoritative accounting for it; re-reporting it
|
|
299
299
|
# through on_unrecoverable would page an operator for normal traffic.
|
|
300
|
-
|
|
300
|
+
# batch.records, not the local `deliverable`: that is only assigned on
|
|
301
|
+
# the first attempt, so a retry that succeeds with rejections would
|
|
302
|
+
# raise here and be retained as if it had failed.
|
|
303
|
+
Railwatch.debug { "ingest rejected #{result.rejected} of #{batch.records.size} records" } if result.rejected.to_i.positive?
|
|
301
304
|
delivery_succeeded
|
|
302
305
|
elsif retryable?(result)
|
|
303
306
|
retain(batch, result)
|