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.
@@ -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|
@@ -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
- Railwatch.debug { "ingest rejected #{result.rejected} of #{deliverable.size} records" } if result.rejected.to_i.positive?
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)