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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0487618698d9dc329892ddabb4762ead8838ce593052b9fcb9fe684163dca15
4
- data.tar.gz: f0704d125a03f31aee2a0a3b60f40413335703e6eb20296e6153885d7ae9b6fb
3
+ metadata.gz: ace6fc321d145d2e579b808a8ce2813b23bc85060d320f39dde2586da8c7db72
4
+ data.tar.gz: 545154019cd93d2d3e605da2b5edf7318265d5c9b43c6e086b514da06a0fe43d
5
5
  SHA512:
6
- metadata.gz: fefe712b31d5b060d1f17856da5e7e05ce1303204ecc8c57b4a8ea59a1f2ea0e9bf427ac66b36201450841802ea7327bf17e9a139e5c448fb0deff851ae5c33d
7
- data.tar.gz: 41119e84bb12b026516e3cd48dc7fd36835f32a88574e3ad25e8244dd5396925dccf9205ef41171943997adb57062e579784b76b9b3b6e7b63dedd69c1aede52
6
+ metadata.gz: 20299db14a1865dcff87eddb613254b2fe83fc7ffc67f55afff8dd4b08e5c4593ee11b6082a4d57a9a7777045c4d5e3fa5df34c5c103bca008fde2bbe2663475
7
+ data.tar.gz: f1105cf854e7b671aeb72b268959e49207101d09afb97f40ac90715d61739562fb2d494f2c21c443f09a86c49f5e2019af998848394c9d368b2ad0c2e602e0aa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-09-19)
4
+
5
+ - An embedded install can now also mirror its telemetry to Railwatch Cloud,
6
+ or to any receiver speaking the same protocol. It is off unless you ask
7
+ for it: `c.export_enabled = true` (or `RAILWATCH_EXPORT_ENABLED=true`),
8
+ reusing the token and ingest URL you already have. A token being present
9
+ is not consent -- an embedded install that has one configured still sends
10
+ nothing.
11
+
12
+ Records are held in a durable queue in your own telemetry database,
13
+ admitted in the same transaction as the rows they mirror, and sent by one
14
+ leased thread. A delivery keeps the exact bytes it will send until the
15
+ receiver acknowledges it, so a retry is the same delivery rather than a
16
+ second one; the receiver recognises repeats and answers with the original
17
+ counts. Local capture never waits on the network, and a queue that cannot
18
+ drain sheds rather than growing without limit. `railwatch:export:status`
19
+ shows what is queued; `railwatch:doctor` reports export health, and fails
20
+ if you asked for mirroring and it cannot work.
21
+
22
+ Embedded and cloud installs are otherwise unchanged: same install, same
23
+ records, same dashboard. See docs/embedded.md.
24
+
25
+ - Ingest acknowledgements are read more carefully. An all-zero
26
+ acknowledgement with no reason no longer counts as a successful delivery
27
+ for a non-empty batch -- nothing legitimate answers a 500-record batch
28
+ that way, but a proxy error page does, and those were being treated as
29
+ stored. A paused or over-quota environment still takes the batch rather
30
+ than burning the retry ladder, and now says so rather than looking like
31
+ storage.
32
+
3
33
  ## 0.2.2 (2026-09-18)
4
34
 
5
35
  - README: describe embedded mode. The gem has had two destinations since
@@ -38,6 +38,7 @@ module Railwatch
38
38
  @session_buckets = Set.new
39
39
  @bucket_cache = {}
40
40
  @exception_ids = []
41
+ @export = nil
41
42
  end
42
43
 
43
44
  # The ledger row for a batch id that has already been written, or nil.
@@ -48,6 +49,10 @@ module Railwatch
48
49
  end
49
50
 
50
51
  def write!
52
+ # Prepared before mapping and outside the transaction: mirroring sends
53
+ # what we were given, not what we kept, and encoding is not something
54
+ # to do while holding the write lock.
55
+ prepare_export
51
56
  map_all
52
57
  log_truncations
53
58
  accepted = 0
@@ -65,7 +70,8 @@ module Railwatch
65
70
  dropped_by_client: @dropped_by_client, backpressure_factor: @backpressure_factor,
66
71
  bytes: @bytes, gem_version: @gem_version,
67
72
  counts_by_type: @counts, rejections: @rejections.first(20),
68
- batch_id: @batch_id, followups: followups)
73
+ batch_id: @batch_id, followups: followups,
74
+ **export_columns)
69
75
  end
70
76
  end
71
77
  end
@@ -76,6 +82,9 @@ module Railwatch
76
82
  @environment.count_events!(accepted)
77
83
  end
78
84
  enqueue_followups
85
+ # After the transaction: the sender must never find a delivery that
86
+ # has not committed yet.
87
+ Export::Sender.wake! if @queued
79
88
  broadcast_live
80
89
  Result.new(accepted: accepted, rejected: @rejections.size, rejections: @rejections)
81
90
  end
@@ -94,6 +103,46 @@ module Railwatch
94
103
 
95
104
  private
96
105
 
106
+ # Mirroring, when this install has been told to. Off is the whole of the
107
+ # cost: no encode, no query, no row, one boolean.
108
+ def prepare_export
109
+ return unless @embedded && Railwatch.config.export?
110
+
111
+ @export = Export::Outbox.new(Railwatch.config, @environment)
112
+ @selections = Export::Policy.fetch(Railwatch.config.export_policy).prepare(
113
+ records: @records, encoder: Transport::WireEncoder.new(batch_bytes: Railwatch.config.batch_bytes),
114
+ source_batch_id: @batch_id || SecureRandom.uuid,
115
+ metadata: { "dropped" => @dropped_by_client, "backpressure_factor" => @backpressure_factor.to_s }
116
+ )
117
+ rescue StandardError => e
118
+ # A batch must still be stored when mirroring cannot be prepared.
119
+ Railwatch.debug { "export preparation failed: #{e.class}: #{e.message}" }
120
+ @export = nil
121
+ @export_error = "shed_encoding"
122
+ end
123
+
124
+ # Runs inside the batch's own transaction, so the rows and the intent to
125
+ # mirror them commit together or not at all.
126
+ # In a savepoint, so a failure here sheds the mirroring and nothing
127
+ # else. Without it an export table that is missing -- a gem upgraded
128
+ # without db:prepare, with export already on -- would roll back the
129
+ # batch that carried it, and every batch after, until nothing was being
130
+ # recorded at all. Monitoring must degrade to local-only rather than
131
+ # stop.
132
+ def export_columns
133
+ return { export_disposition: @export_error } if @export_error
134
+ return {} unless @export
135
+
136
+ admission = TelemetryRecord.transaction(requires_new: true) do
137
+ @export.enqueue!(@selections, now: @received_at)
138
+ end
139
+ @queued = admission.disposition == "queued"
140
+ { export_disposition: admission.disposition, export_record_count: admission.record_count }
141
+ rescue StandardError => e
142
+ Railwatch.debug { "export admission failed: #{e.class}: #{e.message}" }
143
+ { export_disposition: "shed_error" }
144
+ end
145
+
97
146
  ROLLED_UP = %w[request job_attempt scheduled_task command channel_action query outgoing_request cache_event mail visit notification span llm_call].freeze
98
147
  MAX_PAST_AGE = 30.days
99
148
  MAX_FUTURE_AGE = 1.hour
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # One immutable delivery waiting to be sent, or the record of one that no
6
+ # longer is. The body is the exact bytes that will go on the wire, kept
7
+ # until the delivery reaches a terminal state and then freed: a retry has
8
+ # to be the same delivery, and the receiver recognises it by those bytes.
9
+ class ExportDelivery < TelemetryRecord
10
+ DISPOSITIONS = %w[acked rejected expired discarded].freeze
11
+
12
+ belongs_to :export_destination
13
+
14
+ scope :live, -> { where.not(state: "done") }
15
+ scope :due, ->(now = Time.current) { live.where(state: "pending").where(next_attempt_at: ..now) }
16
+ scope :overdue, ->(now = Time.current) { live.where(expires_at: ...now) }
17
+ scope :oldest_first, -> { order(:id) }
18
+
19
+ def live? = state != "done"
20
+
21
+ def sending? = state == "sending"
22
+
23
+ # True when this claim is still the one allowed to finish the delivery.
24
+ # A sender that stalled past its lease may wake and complete its request
25
+ # anyway; the receiver's receipt makes that harmless, but it must not be
26
+ # able to overwrite what the new owner has since recorded.
27
+ def held_by?(token, generation)
28
+ sending? && claim_token == token && claim_generation == generation
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Telemetry
5
+ # Where this database mirrors its telemetry, and everything durable about
6
+ # that relationship: who we are to the receiver, which token we were bound
7
+ # with, whether it is currently taking deliveries, and who holds the lease
8
+ # to send them.
9
+ class ExportDestination < TelemetryRecord
10
+ STATES = %w[ready deferred unauthorized inactive].freeze
11
+ COUNTERS = %w[acked rejected expired discarded shed].freeze
12
+
13
+ has_many :export_deliveries, dependent: :delete_all
14
+
15
+ validates :state, inclusion: { in: STATES }
16
+
17
+ def self.digest(value) = Digest::SHA256.hexdigest(value.to_s)
18
+
19
+ # The binding for this url and token. A different token for the same url
20
+ # is a different binding decision, not a silent rebind: queued bytes
21
+ # were admitted under the old credential and must not follow the new one
22
+ # to whatever tenant it belongs to.
23
+ def self.bind!(url:, token:, now: Time.current, retried: false)
24
+ row = find_or_initialize_by(url_sha256: digest(url))
25
+ row.url = url
26
+ row.producer_id ||= SecureRandom.uuid
27
+ fingerprint = digest(token)
28
+ if row.persisted? && row.credential_sha256 != fingerprint
29
+ row.update!(credential_sha256: fingerprint, state: "unauthorized", reason: "credential_changed",
30
+ retry_at: nil)
31
+ else
32
+ row.credential_sha256 = fingerprint
33
+ row.created_at ||= now
34
+ row.save!
35
+ end
36
+ row
37
+ rescue ActiveRecord::RecordNotUnique
38
+ # Two processes binding for the first time at once. One row wins; the
39
+ # loser wants that row, not an error.
40
+ raise if retried
41
+
42
+ bind!(url: url, token: token, now: now, retried: true)
43
+ end
44
+
45
+ # A pause the receiver asked for ends when it said it would. Only a
46
+ # credential problem needs a person: everything else is a delay, and a
47
+ # delay that never ends is an outage we caused ourselves.
48
+ # Needs a person before anything can move again: a credential that was
49
+ # changed, or a destination taken out of service. Queueing into one
50
+ # just fills it with work that rebind! will throw away.
51
+ def blocked? = %w[unauthorized inactive].include?(state)
52
+
53
+ def sendable?(now: Time.current)
54
+ return false unless %w[ready deferred].include?(state)
55
+
56
+ retry_at.nil? || retry_at <= now
57
+ end
58
+
59
+ def bump!(counter, by = 1)
60
+ return unless COUNTERS.include?(counter.to_s)
61
+
62
+ self.counters = counters.merge(counter.to_s => counters.fetch(counter.to_s, 0) + by)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The durable queue an embedded install uses to mirror its telemetry to a
4
+ # remote receiver.
5
+ #
6
+ # Why the bodies are stored rather than re-derived from the rows next door:
7
+ # the rows are not a reversible copy of what was sent. Mapping renames and
8
+ # drops fields, caps strings, swaps a query's SQL for a shape reference and
9
+ # coalesces people; and ingest rejects records the receiver may well accept,
10
+ # so sending only what we kept would not be the same telemetry. A delivery
11
+ # therefore keeps the exact bytes it will send, and frees them the moment it
12
+ # reaches a terminal state -- the cost tracks what is unacknowledged, not what
13
+ # is stored.
14
+ #
15
+ # Nothing here is written unless export is explicitly enabled.
16
+ class CreateExportQueue < ActiveRecord::Migration[8.1]
17
+ def change
18
+ # One row per destination this database has ever been pointed at.
19
+ create_table :export_destinations do |t|
20
+ t.string :url, null: false
21
+ t.string :url_sha256, limit: 64, null: false
22
+ # Permanent for this database's lineage: it is how the receiver tells
23
+ # our deliveries from another installation's.
24
+ t.string :producer_id, limit: 36, null: false
25
+ # A fingerprint, never the token. If the token changes, queued bytes
26
+ # must not follow it to whatever tenant the new one belongs to.
27
+ t.string :credential_sha256, limit: 64, null: false
28
+
29
+ t.string :state, limit: 16, null: false, default: "ready"
30
+ t.datetime :retry_at, precision: 6
31
+ t.string :reason, limit: 64
32
+
33
+ t.string :lease_owner, limit: 36
34
+ t.bigint :lease_generation, null: false, default: 0
35
+ t.datetime :lease_expires_at, precision: 6
36
+
37
+ # Live totals, maintained in the same transaction as the rows they
38
+ # describe, so admission can be decided without counting the table.
39
+ t.bigint :queued_bytes, null: false, default: 0
40
+ t.bigint :queued_deliveries, null: false, default: 0
41
+ # Lifetime accounting: acked, rejected, expired, discarded, shed.
42
+ t.json :counters, null: false, default: {}
43
+
44
+ t.timestamps
45
+ end
46
+
47
+ add_index :export_destinations, :url_sha256, unique: true
48
+ add_index :export_destinations, :producer_id, unique: true
49
+
50
+ create_table :export_deliveries do |t|
51
+ t.references :export_destination, null: false, foreign_key: true
52
+ # A UUIDv7: its embedded time is how the receiver ages it out, so a
53
+ # delivery cannot be made young again by relabelling it.
54
+ t.string :delivery_id, limit: 36, null: false
55
+ # What this delivery is a delivery OF. One per source batch today; a
56
+ # future policy that selects across batches supplies its own key.
57
+ t.string :selection_key, limit: 160, null: false
58
+
59
+ t.binary :body
60
+ t.string :body_sha256, limit: 64, null: false
61
+ t.string :metadata_sha256, limit: 64, null: false
62
+ t.bigint :body_bytes, null: false
63
+ t.bigint :ndjson_bytes, null: false
64
+ t.integer :record_count, null: false
65
+ # Everything about the delivery that is not its body: version, drop
66
+ # counts, backpressure. Digested into metadata_sha256 so the same id
67
+ # arriving with different counts is a conflict, not an update.
68
+ t.json :wire_metadata, null: false, default: {}
69
+
70
+ t.string :state, limit: 8, null: false, default: "pending"
71
+ # Set only when done: acked, rejected, expired, discarded.
72
+ t.string :disposition, limit: 16
73
+ t.datetime :enqueued_at, null: false, precision: 6
74
+ t.datetime :expires_at, null: false, precision: 6
75
+ t.datetime :next_attempt_at, null: false, precision: 6
76
+ t.bigint :attempts, null: false, default: 0
77
+
78
+ t.string :claim_token, limit: 36
79
+ t.bigint :claim_generation
80
+ t.datetime :claim_expires_at, precision: 6
81
+
82
+ t.integer :last_status
83
+ t.string :last_reason, limit: 64
84
+ t.json :ack
85
+ t.datetime :finished_at, precision: 6
86
+
87
+ t.timestamps
88
+ end
89
+
90
+ add_index :export_deliveries, %i[export_destination_id delivery_id], unique: true,
91
+ name: "index_export_deliveries_on_destination_and_delivery"
92
+ # One delivery per selection: a batch replayed into the same transaction
93
+ # cannot enqueue itself twice.
94
+ add_index :export_deliveries, %i[export_destination_id selection_key], unique: true,
95
+ name: "index_export_deliveries_on_destination_and_selection"
96
+ add_index :export_deliveries, %i[export_destination_id id], where: "state <> 'done'",
97
+ name: "index_export_deliveries_live"
98
+ add_index :export_deliveries, :expires_at, where: "body IS NOT NULL",
99
+ name: "index_export_deliveries_expiring"
100
+ add_index :export_deliveries, :finished_at, where: "state = 'done'",
101
+ name: "index_export_deliveries_finished"
102
+
103
+ # Why a batch did or did not enqueue anything. Null on every row an
104
+ # install without export ever writes.
105
+ add_column :ingest_batches, :export_disposition, :string, limit: 24
106
+ add_column :ingest_batches, :export_record_count, :bigint
107
+ end
108
+ end
data/docs/embedded.md CHANGED
@@ -14,6 +14,36 @@ place for many apps, point the gem at Railwatch Cloud instead
14
14
  ([Getting started](getting-started.md)); the two are switchable with
15
15
  one setting.
16
16
 
17
+ ## Three ways to run it
18
+
19
+ Same gem, same install, same records. The only question is where they end
20
+ up:
21
+
22
+ | | Records live | Dashboard |
23
+ |---|---|---|
24
+ | **Embedded** | your app's SQLite files | `/railwatch` in your app |
25
+ | **Cloud** | Railwatch Cloud | the hosted one |
26
+ | **Both** | your app's files, *and* Railwatch Cloud | either |
27
+
28
+ Embedded is `c.transport = :local`, which is what `--local` writes.
29
+ Cloud is the default. "Both" is embedded plus one more line:
30
+
31
+ ```ruby
32
+ c.export_enabled = true # or RAILWATCH_EXPORT_ENABLED=true
33
+ ```
34
+
35
+ It reuses the token and ingest URL you already have, so an install that
36
+ was pointed at the cloud and moved to embedded needs nothing else to send
37
+ to both. Everything captured locally is mirrored — the same records the
38
+ same install would have sent had you chosen the cloud — so the hosted
39
+ dashboard is as complete as it would be either way.
40
+
41
+ It is off unless you set that flag. A token being present is not consent:
42
+ an embedded install that has one configured still sends nothing.
43
+ `railwatch:doctor` says nothing about export until you ask for it, and
44
+ fails loudly if you ask for it and it cannot work. `railwatch:export:status`
45
+ shows what is queued.
46
+
17
47
  ## Install
18
48
 
19
49
  ```sh
@@ -65,6 +65,8 @@ module Railwatch
65
65
  # "they left the default".
66
66
  DEFAULT_BASE_CONTROLLER = "ActionController::Base"
67
67
 
68
+ attr_accessor :export_enabled, :export_policy, :export_url, :export_token, :export_max_bytes,
69
+ :export_max_deliveries, :export_max_age
68
70
  attr_accessor :enabled, :token, :ingest_url, :allow_http, :server, :environment, :transport,
69
71
  :issue_prefix, :repository_url, :retention_days, :dashboard_user, :writer_socket,
70
72
  :http_basic_auth_enabled, :http_basic_auth_user, :http_basic_auth_password, :base_controller_class,
@@ -103,6 +105,16 @@ module Railwatch
103
105
  @repository_url = ENV["RAILWATCH_REPOSITORY_URL"]
104
106
  @retention_days = env_int("RAILWATCH_RETENTION_DAYS", 7)
105
107
  @dashboard_user = nil
108
+ # Mirroring an embedded install's telemetry to a remote receiver. Off
109
+ # unless asked for: an embedded install's promise is that nothing leaves
110
+ # the machine, and a token being present is not consent.
111
+ @export_enabled = env_bool("RAILWATCH_EXPORT_ENABLED", false)
112
+ @export_policy = ENV.fetch("RAILWATCH_EXPORT_POLICY", "everything").to_sym
113
+ @export_url = ENV["RAILWATCH_EXPORT_URL"]
114
+ @export_token = ENV["RAILWATCH_EXPORT_TOKEN"]
115
+ @export_max_bytes = env_int("RAILWATCH_EXPORT_MAX_BYTES", 256 * 1024 * 1024)
116
+ @export_max_deliveries = env_int("RAILWATCH_EXPORT_MAX_DELIVERIES", 100_000)
117
+ @export_max_age = env_int("RAILWATCH_EXPORT_MAX_AGE_SECONDS", 86_400)
106
118
  # Dashboard access, the way Mission Control Jobs does it: HTTP Basic
107
119
  # authentication is on and CLOSED by default. With no user and password
108
120
  # configured every dashboard request is 401, so an install that forgot
@@ -300,6 +312,46 @@ module Railwatch
300
312
  # anything else ships it to ingest_url over HTTPS.
301
313
  def local? = transport.to_s == "local"
302
314
 
315
+ # The receiver admits an unseen delivery for seven days; queueing one for
316
+ # longer cannot help.
317
+ MAX_EXPORT_AGE = 7 * 24 * 60 * 60
318
+
319
+ # Mirroring is a thing an embedded install opts into; it is meaningless
320
+ # for an install that is already sending everything over HTTP.
321
+ def export? = export_enabled && local? && export_problem.nil?
322
+
323
+ # Why export is configured but unusable, or nil when it is fine. The
324
+ # doctor reports this; nothing silently half-enables.
325
+ def export_problem
326
+ return nil unless export_enabled
327
+ return "export needs transport :local; an :http install already sends everything" unless local?
328
+ return "RAILWATCH_EXPORT_POLICY #{export_policy} is not implemented" unless export_policy.to_s == "everything"
329
+ return "no export token: set RAILWATCH_EXPORT_TOKEN or RAILWATCH_TOKEN" if resolved_export_token.to_s.empty?
330
+ return "no export url: set RAILWATCH_EXPORT_URL or RAILWATCH_INGEST_URL" if resolved_export_url.to_s.empty?
331
+ return "export url must be HTTPS (or set RAILWATCH_ALLOW_HTTP=true)" unless url_allowed?(resolved_export_url)
332
+ # Past this a receiver stops recognising a delivery's id, so holding one
333
+ # any longer just means discovering later that it can never be sent.
334
+ if export_max_age > MAX_EXPORT_AGE
335
+ return "RAILWATCH_EXPORT_MAX_AGE_SECONDS cannot exceed #{MAX_EXPORT_AGE} (the receiver stops recognising a delivery past that)"
336
+ end
337
+
338
+ nil
339
+ end
340
+
341
+ # The receiver, and the credential we are bound to it with. Both fall back
342
+ # to the ordinary ingest settings so switching an install from embedded to
343
+ # cloud needs no second set of values.
344
+ def resolved_export_url
345
+ url = export_url.presence || (ingest_url.presence && URI.join(ingest_url, "/ingest").to_s)
346
+ url&.sub(%r{/\z}, "")
347
+ rescue URI::Error
348
+ # BadURIError (a relative ingest_url) is not an InvalidURIError, and
349
+ # letting it out of here takes the doctor down with it.
350
+ nil
351
+ end
352
+
353
+ def resolved_export_token = export_token.presence || token
354
+
303
355
  # Absolute path of the writer socket, or nil when the writer is off.
304
356
  def writer_socket_path
305
357
  path = writer_socket.to_s
@@ -373,7 +425,15 @@ module Railwatch
373
425
  end
374
426
 
375
427
  def ingest_url_allowed?
376
- uri = URI.parse(ingest_url.to_s)
428
+ url_allowed?(ingest_url)
429
+ end
430
+
431
+ # The same policy, applied to whatever URL is actually about to be
432
+ # requested. A transport pointed at an explicit endpoint must be judged on
433
+ # that endpoint: approving it because some other configured URL happens to
434
+ # be HTTPS would put the token on the wire in plaintext.
435
+ def url_allowed?(url)
436
+ uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
377
437
  return true if uri.scheme == "https"
378
438
  return false unless uri.scheme == "http"
379
439
 
@@ -216,6 +216,17 @@ module Railwatch
216
216
  at_exit { Railwatch::Maintenance.stop! }
217
217
  end
218
218
 
219
+ # Draining the export queue. Same shape and ordering as the others:
220
+ # stopped before the reporter's final flush, so its last claim is
221
+ # released rather than left to expire. Sender.start! is a no-op unless
222
+ # export is configured and usable.
223
+ initializer "railwatch.export" do
224
+ next unless Railwatch.enabled?
225
+
226
+ Railwatch::Export::Sender.start!
227
+ at_exit { Railwatch::Export::Sender.stop! }
228
+ end
229
+
219
230
  # lib/tasks/railwatch_tasks.rake is picked up by Rails::Engine's default
220
231
  # lib/tasks convention; the rake_tasks block above only installs the
221
232
  # Rake::Task patch.
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Export
5
+ # Speaks the receipt protocol: names the delivery so the receiver can
6
+ # recognise a repeat of it, and turns the answer into something the queue
7
+ # can act on.
8
+ #
9
+ # It makes one attempt and has no opinion about when to try again. That
10
+ # belongs to the queue, which is the thing with durable storage.
11
+ class Client
12
+ Outcome = Struct.new(:disposition, :status, :reason, :retry_after_at, :ack, keyword_init: true)
13
+
14
+ # The queue is bound to a fingerprint of the export credential, so the
15
+ # request has to be made with that credential: authenticating as someone
16
+ # else would deliver these bytes to a tenant that never admitted them.
17
+ def initialize(config)
18
+ @config = config
19
+ @transports = {}
20
+ end
21
+
22
+ # Not `send`: shadowing Object#send on a class makes it impossible to
23
+ # reach the real one, and reads as a coincidence rather than a verb.
24
+ def deliver(claim, producer_id:)
25
+ result = transport_for(claim).deliver_encoded(
26
+ body: claim.body, expected_count: claim.record_count, batch_id: claim.delivery_id,
27
+ dropped: claim.metadata.fetch("dropped", 0).to_i,
28
+ dropped_bytes: claim.metadata.fetch("dropped_bytes", 0).to_i,
29
+ # Stored with the delivery, not read from the reporter now: this is
30
+ # what the batch was carrying when it was queued.
31
+ backpressure_factor: claim.metadata.fetch("backpressure_factor", 1.0).to_f,
32
+ gem_version: claim.metadata.fetch("version", Railwatch::VERSION),
33
+ headers: headers(claim, producer_id)
34
+ )
35
+ interpret(result)
36
+ end
37
+
38
+ def reset_after_fork!
39
+ @transports = {}
40
+ self
41
+ end
42
+
43
+ # Drops any latch the transports have picked up. After a credential
44
+ # problem is fixed, the next send must be an actual send.
45
+ def reset!
46
+ @transports = {}
47
+ self
48
+ end
49
+
50
+ private
51
+
52
+ # Keyed on the destination this delivery is FOR, not on whatever the
53
+ # configuration says right now. A process reconfigured mid-flight would
54
+ # otherwise post bytes admitted for one receiver to another one -- and,
55
+ # if they belong to different tenants, hand a customer's telemetry to
56
+ # somebody else while marking it delivered.
57
+ def transport_for(claim)
58
+ key = [ claim.url, claim.token_digest ]
59
+ @transports[key] ||= begin
60
+ credentialed = @config.dup
61
+ credentialed.token = @config.resolved_export_token
62
+ Transport::Http.new(credentialed, endpoint: claim.url)
63
+ end
64
+ end
65
+
66
+ def headers(claim, producer_id)
67
+ {
68
+ "X-Railwatch-Producer-Id" => producer_id,
69
+ "X-Railwatch-Body-SHA256" => Digest::SHA256.hexdigest(claim.body),
70
+ "X-Railwatch-Policy" => claim.metadata.fetch("policy", Policy::Everything::VERSION)
71
+ }
72
+ end
73
+
74
+ # The receiver's answer, reduced to what the queue needs to decide.
75
+ # Anything it cannot read is treated as "not stored": keeping bytes we
76
+ # might not need costs a retry, discarding bytes that never arrived
77
+ # costs the telemetry.
78
+ STORED = %w[committed already_committed].freeze
79
+ REJECTING_STATUSES = [ 400, 409, 410, 413, 422 ].freeze
80
+
81
+ def interpret(result)
82
+ return Outcome.new(disposition: :stored, status: result.status, ack: result.to_h) if stored?(result)
83
+ return Outcome.new(disposition: :rejected, status: result.status, reason: reason_for(result)) if rejected?(result)
84
+
85
+ Outcome.new(disposition: :deferred, status: result.status, reason: reason_for(result),
86
+ retry_after_at: result.retry_after_at)
87
+ end
88
+
89
+ # A 200 that stored the records, or a duplicate the receiver already
90
+ # holds. When the receiver names what it did, we believe the name and
91
+ # not the arithmetic: counts that happen to add up are not a receipt.
92
+ def stored?(result)
93
+ return false unless result.ok && !result.deferred?
94
+ return STORED.include?(result.ack_disposition) if result.ack_disposition
95
+
96
+ true
97
+ end
98
+
99
+ # Permanently unacceptable TO THE RECEIVER: sending it again cannot
100
+ # change the answer. 409 is a delivery id reused for different bytes,
101
+ # 410 one too old to be recognised.
102
+ #
103
+ # Deliberately not our own refusals. `:permanent` from the transport
104
+ # means we declined to send -- a latched credential failure, a
105
+ # configuration we will not use -- and throwing the bytes away because
106
+ # of something on this side would destroy telemetry that was never
107
+ # offered to anyone.
108
+ def rejected?(result)
109
+ REJECTING_STATUSES.include?(result.status)
110
+ end
111
+
112
+ def reason_for(result)
113
+ (result.reason || result.error || result.status).to_s[0, 64]
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Export
5
+ # Which process is allowed to send, right now.
6
+ #
7
+ # Every eligible process runs a sender; the lease decides which one does
8
+ # anything. It is held for a short time and renewed, so a process that
9
+ # dies holding it blocks the queue for seconds rather than forever.
10
+ #
11
+ # The generation is a fence. A holder that stalls past its expiry may wake
12
+ # and finish a request it had already started -- the receipt at the other
13
+ # end makes that harmless -- but it must not then be able to overwrite
14
+ # what the new holder has since recorded. Every write it attempts carries
15
+ # the generation it was granted, and a stale one matches nothing.
16
+ module Lease
17
+ TTL = 30
18
+ # Renewed well inside the TTL: a renewal that has to wait on the write
19
+ # lock still has room to land before the lease it is extending lapses.
20
+ RENEW_EVERY = 10
21
+
22
+ module_function
23
+
24
+ # Takes the lease, or extends it if we already hold it. Returns the
25
+ # generation we hold it under, or nil if someone else has it.
26
+ def acquire(destination_id, owner:, now: Time.current)
27
+ taken = Telemetry::ExportDestination
28
+ .where(id: destination_id)
29
+ .where("lease_owner IS NULL OR lease_owner = ? OR lease_expires_at < ?", owner, now)
30
+ .update_all([
31
+ "lease_owner = ?, lease_expires_at = ?, lease_generation = lease_generation + 1, updated_at = ?",
32
+ owner, now + TTL, now
33
+ ])
34
+ return nil if taken.zero?
35
+
36
+ Telemetry::ExportDestination.where(id: destination_id).pick(:lease_generation)
37
+ end
38
+
39
+ # Extends a lease we still hold. Cannot revive an expired one: taking it
40
+ # again is acquire's job, and that increments the generation so anything
41
+ # in flight under the old one is fenced out.
42
+ def renew(destination_id, owner:, generation:, now: Time.current)
43
+ Telemetry::ExportDestination
44
+ .where(id: destination_id, lease_owner: owner, lease_generation: generation)
45
+ .where(lease_expires_at: now..)
46
+ .update_all([ "lease_expires_at = ?, updated_at = ?", now + TTL, now ]) == 1
47
+ end
48
+
49
+ def release(destination_id, owner:, generation:, now: Time.current)
50
+ Telemetry::ExportDestination
51
+ .where(id: destination_id, lease_owner: owner, lease_generation: generation)
52
+ .update_all([ "lease_owner = NULL, lease_expires_at = NULL, updated_at = ?", now ]) == 1
53
+ end
54
+ end
55
+ end
56
+ end