coverband 6.2.2 → 7.0.0.rc.2

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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/diagram.yml +2 -2
  3. data/.github/workflows/main.yml +64 -7
  4. data/Gemfile +16 -0
  5. data/Gemfile.rails7.0 +12 -0
  6. data/Gemfile.rails7.1 +6 -1
  7. data/Gemfile.rails7.2 +5 -0
  8. data/Gemfile.rails8.0 +15 -0
  9. data/README.md +218 -12
  10. data/Rakefile +16 -5
  11. data/changes.md +60 -0
  12. data/coverband.gemspec +15 -13
  13. data/lib/coverband/adapters/active_support_cache_store.rb +54 -0
  14. data/lib/coverband/adapters/base.rb +42 -10
  15. data/lib/coverband/adapters/file_store.rb +2 -2
  16. data/lib/coverband/adapters/hash_redis_store.rb +22 -3
  17. data/lib/coverband/adapters/null_store.rb +0 -4
  18. data/lib/coverband/adapters/redis_cleanup.rb +101 -0
  19. data/lib/coverband/adapters/redis_store.rb +32 -65
  20. data/lib/coverband/adapters/session_coverage.rb +172 -0
  21. data/lib/coverband/adapters/stdout_store.rb +0 -4
  22. data/lib/coverband/adapters/tracker_storage/base.rb +79 -0
  23. data/lib/coverband/adapters/tracker_storage/document_repository.rb +49 -0
  24. data/lib/coverband/adapters/tracker_storage/factory.rb +57 -0
  25. data/lib/coverband/adapters/tracker_storage/redis_hash_repository.rb +127 -0
  26. data/lib/coverband/collectors/abstract_tracker.rb +135 -51
  27. data/lib/coverband/collectors/query_burst_tracker.rb +72 -19
  28. data/lib/coverband/collectors/route_tracker.rb +6 -0
  29. data/lib/coverband/collectors/tracker_registry.rb +63 -0
  30. data/lib/coverband/collectors/translation_tracker.rb +6 -0
  31. data/lib/coverband/collectors/view_tracker.rb +11 -5
  32. data/lib/coverband/configuration.rb +95 -131
  33. data/lib/coverband/mcp/http_handler.rb +1 -1
  34. data/lib/coverband/mcp/server.rb +4 -1
  35. data/lib/coverband/mcp/tools/get_storage_health.rb +28 -0
  36. data/lib/coverband/reporters/base.rb +0 -2
  37. data/lib/coverband/reporters/html_report.rb +2 -3
  38. data/lib/coverband/reporters/json_report.rb +2 -2
  39. data/lib/coverband/reporters/web.rb +72 -15
  40. data/lib/coverband/storage/document.rb +173 -0
  41. data/lib/coverband/storage/generation.rb +165 -0
  42. data/lib/coverband/storage/generation_change.rb +53 -0
  43. data/lib/coverband/storage/generation_coordinator.rb +145 -0
  44. data/lib/coverband/storage/io_guard.rb +29 -0
  45. data/lib/coverband/storage/read_fallback.rb +30 -0
  46. data/lib/coverband/storage/redis_target.rb +74 -0
  47. data/lib/coverband/storage/session.rb +527 -0
  48. data/lib/coverband/storage/target.rb +117 -0
  49. data/lib/coverband/storage/writer.rb +194 -0
  50. data/lib/coverband/storage_health.rb +90 -0
  51. data/lib/coverband/utils/configuration_template.rb +14 -1
  52. data/lib/coverband/utils/railtie.rb +2 -2
  53. data/lib/coverband/utils/result.rb +5 -5
  54. data/lib/coverband/utils/results.rb +32 -16
  55. data/lib/coverband/utils/source_file.rb +17 -27
  56. data/lib/coverband/utils/tasks.rb +71 -6
  57. data/lib/coverband/version.rb +1 -1
  58. data/lib/coverband.rb +98 -20
  59. data/public/application.css +540 -146
  60. data/test/benchmarks/benchmark.rake +13 -0
  61. data/test/coverband/adapters/active_support_cache_store_test.rb +613 -0
  62. data/test/coverband/adapters/capabilities_test.rb +80 -0
  63. data/test/coverband/adapters/redis_store_test.rb +63 -2
  64. data/test/coverband/adapters/solid_cache_store_test.rb +181 -0
  65. data/test/coverband/adapters/tracker_storage_test.rb +170 -0
  66. data/test/coverband/collectors/query_burst_tracker_test.rb +155 -10
  67. data/test/coverband/collectors/route_tracker_test.rb +2 -13
  68. data/test/coverband/collectors/tracker_registry_test.rb +218 -0
  69. data/test/coverband/collectors/translation_tracker_test.rb +76 -16
  70. data/test/coverband/collectors/view_tracker_test.rb +49 -12
  71. data/test/coverband/configuration_test.rb +36 -38
  72. data/test/coverband/mcp/server_test.rb +2 -1
  73. data/test/coverband/mcp/tools/get_storage_health_test.rb +41 -0
  74. data/test/coverband/reporters/html_test.rb +26 -0
  75. data/test/coverband/reporters/json_test.rb +53 -0
  76. data/test/coverband/reporters/web_test.rb +96 -0
  77. data/test/coverband/storage/generation_coordinator_test.rb +191 -0
  78. data/test/coverband/storage/io_guard_test.rb +127 -0
  79. data/test/coverband/storage/protocol_conformance_test.rb +725 -0
  80. data/test/coverband/storage_health_test.rb +99 -0
  81. data/test/coverband/track_key_test.rb +8 -0
  82. data/test/coverband/utils/cleanup_tasks_test.rb +188 -0
  83. data/test/coverband/utils/file_list_test.rb +2 -2
  84. data/test/coverband/utils/result_test.rb +4 -4
  85. data/test/coverband/utils/results_test.rb +100 -10
  86. data/test/coverband/utils/source_file_test.rb +40 -6
  87. data/test/forked/rails_cache_full_stack_test.rb +64 -0
  88. data/test/forked/rails_full_stack_test.rb +7 -0
  89. data/test/integration/full_stack_deferred_eager_test.rb +0 -1
  90. data/test/integration/full_stack_send_deferred_eager_test.rb +0 -1
  91. data/test/integration/full_stack_test.rb +0 -1
  92. data/test/integration/mcp_integration_test.rb +4 -2
  93. data/test/integration/standalone_json_report_test.rb +46 -0
  94. data/test/integration/without_redis_test.rb +76 -0
  95. data/test/rails7_dummy/config/application.rb +1 -0
  96. data/test/rails7_dummy/config/coverband_missing_redis.rb +0 -2
  97. data/test/rails7_dummy/config/coverband_rails_cache.rb +16 -0
  98. data/test/rails8_dummy/config/application.rb +1 -0
  99. data/test/rails8_dummy/config/coverband_rails_cache.rb +3 -0
  100. data/test/rails_test_helper.rb +2 -1
  101. data/test/test_helper.rb +5 -17
  102. data/views/file_list.erb +2 -2
  103. data/views/source_file.erb +4 -3
  104. metadata +48 -40
  105. data/lib/coverband/adapters/memcached_store.rb +0 -85
  106. data/lib/coverband/adapters/web_service_store.rb +0 -152
  107. data/lib/coverband/collectors/view_tracker_service.rb +0 -59
  108. data/test/coverband/adapters/memecached_store_test.rb +0 -26
  109. data/test/coverband/adapters/web_service_store_test.rb +0 -56
@@ -0,0 +1,527 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "document"
4
+ require_relative "writer"
5
+ require_relative "generation_coordinator"
6
+ require_relative "read_fallback"
7
+
8
+ module Coverband
9
+ module Storage
10
+ ###
11
+ # Runs the merge protocol for one document.
12
+ #
13
+ # Conflict is repaired, not prevented: a clobbered write converges while the
14
+ # originating writer is alive and its delta is still pending. Applied
15
+ # sequences are what detect the conflict, and for non-idempotent merges they
16
+ # are also what makes the retry safe, since re-applying a sum would double
17
+ # count.
18
+ ###
19
+ class Session
20
+ include ReadFallback
21
+
22
+ # every pointer a reporting cycle needs, in one round trip instead of one
23
+ # small read per document
24
+ def self.prefetch_pointers(target, sessions)
25
+ sessions = Array(sessions).compact.select { |session| session.respond_to?(:pointer_key) }
26
+ return if sessions.length < 2
27
+ return unless target.respond_to?(:read_multi)
28
+
29
+ found = target.read_multi(*sessions.map(&:pointer_key))
30
+ sessions.each do |session|
31
+ raw = found[session.pointer_key]
32
+ next if raw.nil?
33
+
34
+ # parsed here so the batch's raw strings are not held after it returns
35
+ parsed = begin
36
+ raw.is_a?(Hash) ? raw : JSON.parse(raw)
37
+ rescue JSON::ParserError
38
+ nil
39
+ end
40
+ session.prime_pointer(parsed) if parsed
41
+ end
42
+ rescue => error
43
+ # an optimization only: on failure each session reads its own pointer
44
+ Coverband.configuration.logger&.debug("Coverband: pointer prefetch failed #{error.class}")
45
+ end
46
+
47
+ DataLoss = Struct.new(:at, :kind, :detail, keyword_init: true)
48
+
49
+ # held because it cannot be written. DataLoss says something is gone; this
50
+ # says nothing is arriving, which for a quiet document is otherwise
51
+ # indistinguishable from an app that used nothing
52
+ UnwrittenWork = Struct.new(:deltas, :since, keyword_init: true)
53
+
54
+ DEFAULT_MAX_ENTRIES = 5
55
+ DEFAULT_MAX_BYTES = 8 * 1024 * 1024
56
+ # stays well under the pruning horizon so a delta can never outlive its
57
+ # own guard, or the tombstone that would have filtered it
58
+ DEFAULT_MAX_AGE = 60 * 60
59
+ DEFAULT_PRUNE_HORIZON = 12 * 60 * 60
60
+ KEEP_ALIVE_AFTER = 3 * 24 * 60 * 60
61
+
62
+ attr_reader :data_loss
63
+
64
+ ###
65
+ # A document that can never be written -- past memcached's value limit, say
66
+ # -- reports nothing otherwise: its caps never fire, because a quiet tracker
67
+ # enqueues nothing new to drop, so only the age cap converts the stall into
68
+ # a loss, an hour later by default.
69
+ ###
70
+ def unwritten
71
+ return nil unless @unwritten_since
72
+ return nil if @writer.pending_size.zero?
73
+
74
+ UnwrittenWork.new(deltas: @writer.pending_size, since: Time.at(@unwritten_since))
75
+ end
76
+
77
+ def initialize(target:, key_base:, merger:, logger: nil,
78
+ max_entries: DEFAULT_MAX_ENTRIES, max_bytes: DEFAULT_MAX_BYTES,
79
+ max_age: DEFAULT_MAX_AGE, prune_horizon: DEFAULT_PRUNE_HORIZON,
80
+ grace_seconds: 1200, keep_alive_after: KEEP_ALIVE_AFTER,
81
+ on_generation_change: nil, retain_on_failure: false)
82
+ @target = target
83
+ @key_base = key_base
84
+ @merger = merger
85
+ @logger = logger
86
+ @prune_horizon = prune_horizon
87
+ @keep_alive_after = keep_alive_after
88
+ @on_generation_change = on_generation_change
89
+ @retain_on_failure = retain_on_failure
90
+ @writer = Writer.new(max_entries: max_entries, max_bytes: max_bytes, max_age: max_age)
91
+ @generation_coordinator = GenerationCoordinator.new(
92
+ target: target,
93
+ key_base: key_base,
94
+ grace_seconds: grace_seconds,
95
+ on_change: ->(change) { handle_generation_change(change) }
96
+ )
97
+ @data_loss = nil
98
+ @last_write_at = nil
99
+ end
100
+
101
+ def generation_token
102
+ safely { @generation_coordinator.token }
103
+ end
104
+
105
+ def pointer_key
106
+ @generation_coordinator.pointer_key
107
+ end
108
+
109
+ def prime_pointer(pointer)
110
+ @generation_coordinator.prime_pointer(pointer)
111
+ end
112
+
113
+ def entries
114
+ safely({}) { operation { document.payload } }
115
+ end
116
+
117
+ def tracking_since
118
+ safely do
119
+ operation do
120
+ started = document.started_at
121
+ started ? Time.at(started) : nil
122
+ end
123
+ end
124
+ end
125
+
126
+ def pending_size
127
+ @writer.pending_size
128
+ end
129
+
130
+ ###
131
+ # Bytes stored, or nil for nothing stored. Goes through operation because a
132
+ # freshly built session has no token yet, and reading its key directly
133
+ # would address ".g" and report nothing for coverage that is really there.
134
+ ###
135
+ def stored_size
136
+ safely {
137
+ operation {
138
+ raw = @target.read(data_key)
139
+ raw&.to_s&.bytesize
140
+ }
141
+ }
142
+ end
143
+
144
+ def enqueue(payload)
145
+ operation { @writer.enqueue(payload, tombstone_epoch: observed_tombstone_epoch) }
146
+ end
147
+
148
+ def enqueue_delete(key)
149
+ operation do
150
+ @writer.enqueue({}, tombstone_epoch: observed_tombstone_epoch, kind: :delete, key: key.to_s)
151
+ end
152
+ end
153
+
154
+ ###
155
+ # An empty payload still needs the flush, to confirm earlier work and run
156
+ # the keep-alive; enqueuing it would rewrite the document every quiet cycle.
157
+ #
158
+ # The generation is resolved before the payload is enqueued, and retention
159
+ # only protects deltas that reached the queue, so anything that did not
160
+ # get there is held for the next cycle.
161
+ ###
162
+ def record(payload)
163
+ # "nothing to take" is not "taken": an empty payload leaves the caller
164
+ # holding nothing, so a failure there still has to be raised
165
+ nothing_to_take = payload.nil? || payload.empty?
166
+ taken = false
167
+ operation do
168
+ unless nothing_to_take || taken
169
+ enqueue(payload)
170
+ taken = true
171
+ end
172
+ flush
173
+ end
174
+ rescue Target::Unavailable => error
175
+ retain(payload) unless taken || nothing_to_take
176
+ log_unavailable(error)
177
+ taken ? :retained : :unavailable
178
+ rescue => error
179
+ # whether the work is ours decides who keeps it, and whether this may
180
+ # raise at all: a caller that sees an exception keeps its copy, so raising
181
+ # after we took the delta gives it two owners and replays it
182
+ if taken
183
+ note_unwritten
184
+ log("failed to store #{@key_base}, retaining #{@writer.pending_size} pending deltas, " \
185
+ "#{error.class}: #{error.message}")
186
+ :retained
187
+ else
188
+ retain(payload) unless nothing_to_take
189
+ raise
190
+ end
191
+ end
192
+
193
+ ###
194
+ # A fresh token retires the whole generation, so stragglers write to a key
195
+ # nothing reads. False when the pointer write didn't land: a reset that
196
+ # isn't durable is a failure, never a silent partial reset.
197
+ ###
198
+ def reset
199
+ @generation_coordinator.reset
200
+ rescue Target::Unavailable => error
201
+ log_unavailable(error)
202
+ false
203
+ end
204
+
205
+ def delete_entry(key)
206
+ operation do
207
+ enqueue_delete(key)
208
+ flush
209
+ end
210
+ end
211
+
212
+ def flush
213
+ operation do
214
+ doc = document
215
+ watermark = resolve_watermark(doc)
216
+
217
+ confirmed = @writer.confirm_through(watermark)
218
+
219
+ dropped = @writer.enforce_caps!
220
+ if dropped.any?
221
+ record_dropped(dropped)
222
+ # the dropped work is gone for good, so step over the hole it left
223
+ # rather than stalling on it forever
224
+ @writer.rebase_pending!(watermark)
225
+ @written_through = nil
226
+ end
227
+
228
+ prefix = @writer.contiguous_prefix(watermark)
229
+ # pending work yielding no prefix is a sequence hole, left by a drop
230
+ # that had no watermark to rebase against. Closing it here rather than
231
+ # tracking who owes a rebase keeps the invariant structural
232
+ if prefix.empty? && @writer.pending_size > 0
233
+ @writer.rebase_pending!(watermark)
234
+ @written_through = nil
235
+ prefix = @writer.contiguous_prefix(watermark)
236
+ end
237
+
238
+ if prefix.empty?
239
+ if @data_loss && !@data_loss_persisted
240
+ persist_data_loss(doc)
241
+ write(doc) # a refused write stays unpersisted and retries next cycle
242
+ end
243
+ keep_alive(doc)
244
+ next((confirmed > 0) ? :confirmed : :deferred)
245
+ end
246
+
247
+ persist_data_loss(doc)
248
+ apply(doc, prefix)
249
+ doc.record_watermark(@writer.writer_id, prefix.last.seq, host: @writer.host, pid: @writer.pid)
250
+ doc.started_at!
251
+ doc.prune!(horizon: @prune_horizon)
252
+
253
+ if write(doc)
254
+ @written_through = prefix.last.seq
255
+ :written_unconfirmed
256
+ else
257
+ # not durable, but ours: everything stays pending for the next cycle,
258
+ # so a caller holding a second copy would replay it
259
+ note_unwritten
260
+ log("failed to write #{@key_base} (#{doc.to_json.bytesize} bytes), retaining " \
261
+ "#{@writer.pending_size} pending deltas. A backend value limit (memcached's is " \
262
+ "1MB by default) will refuse this every cycle until the limit is raised or the " \
263
+ "store is changed")
264
+ :retained
265
+ end
266
+ end
267
+ end
268
+
269
+ def clear_local_data_loss!
270
+ @data_loss = nil
271
+ end
272
+
273
+ ###
274
+ # Drops the state a reporting cycle accumulates: unconfirmed deltas, and
275
+ # any pointer primed by the cycle's batched read. Both are bounded and
276
+ # deliberate, but they are held between cycles, so a leak check has to be
277
+ # able to put the session back to a quiet baseline. Forfeits the repair
278
+ # those deltas would have done, so it is for benchmarks and shutdown only.
279
+ ###
280
+ def discard_pending!
281
+ @writer.clear_pending!
282
+ @generation_coordinator.discard_primed_pointer!
283
+ end
284
+
285
+ ###
286
+ # Keys another process deleted since we last looked, drained from what the
287
+ # last read already told us. Presence trackers dedupe locally and forever,
288
+ # so without this a cleared key is never enqueued again however often it
289
+ # is used.
290
+ ###
291
+ def newly_tombstoned
292
+ drained = @tombstone_notifications || []
293
+ @tombstone_notifications = []
294
+ drained
295
+ end
296
+
297
+ private
298
+
299
+ ###
300
+ # Holds a payload that never reached the queue, stamped with the last epoch
301
+ # observed -- zero if none, which can filter the delta against an existing
302
+ # tombstone but never resurrect a deleted key.
303
+ #
304
+ # Off by default: only a caller that hands work over and forgets it can use
305
+ # this. Coverage does; a tracker keeps its own keys, so retaining here too
306
+ # replays them, which for the additive trackers double counts.
307
+ #
308
+ # The caps run here because a sustained outage never reaches flush, where
309
+ # they normally do. Dropping leaves a sequence hole only a flush can close,
310
+ # since only it knows the watermark to rebase against.
311
+ ###
312
+ # a misconfiguration can never resolve, so it is said once; "not ready yet"
313
+ # is said each cycle, because each line is a cycle that was deferred
314
+ def log_unavailable(error)
315
+ return log(error.message) unless error.is_a?(Target::Misconfigured)
316
+ return if @misconfiguration_logged
317
+
318
+ @misconfiguration_logged = true
319
+ @logger&.error("Coverband: #{error.message}")
320
+ end
321
+
322
+ def retain(payload)
323
+ return unless @retain_on_failure
324
+ return if payload.nil? || payload.empty?
325
+
326
+ @writer.enqueue(payload, tombstone_epoch: @observed_tombstone_epoch.to_i)
327
+ note_unwritten
328
+ record_dropped(@writer.enforce_caps!)
329
+ rescue => error
330
+ log("could not retain work for #{@key_base}, #{error.class}: #{error.message}")
331
+ end
332
+
333
+ def apply(doc, prefix)
334
+ prefix.each do |delta|
335
+ if delta.kind == :delete
336
+ doc.add_tombstone(delta.key)
337
+ else
338
+ @merger.call(doc, delta)
339
+ end
340
+ end
341
+ end
342
+
343
+ def resolve_watermark(doc)
344
+ watermark = doc.watermark_for(@writer.writer_id)
345
+
346
+ # having seen its own watermark and then found it gone, a writer cannot
347
+ # tell whether its deltas are in the payload; re-applying could double
348
+ # count and assuming durability could lose data, so it becomes someone
349
+ # else and gives up the ambiguous deltas
350
+ if !doc.watermark_present?(@writer.writer_id) && @writer.observed_watermark? && @writer.pending_size > 0
351
+ abandoned = @writer.rotate_identity!
352
+ record_loss(:identity_rotated, "abandoned #{abandoned.length} deltas with an ambiguous watermark")
353
+ return 0
354
+ end
355
+
356
+ @writer.observed_watermark!(watermark)
357
+ watermark
358
+ end
359
+
360
+ def document
361
+ raw = @target.read(data_key)
362
+ doc = Document.parse(raw)
363
+
364
+ if doc.nil?
365
+ record_loss(:corrupt_document, "unparseable document at #{data_key}")
366
+ doc = Document.new
367
+ elsif raw.nil? && @seen_document
368
+ record_loss(:eviction, "document #{data_key} disappeared")
369
+ @seen_document = false
370
+ # trackers dedupe locally and forever, so the keys they could still
371
+ # re-report would stay unreported after an eviction
372
+ @generation_coordinator.document_evicted!
373
+ end
374
+
375
+ @seen_document = true unless raw.nil?
376
+ # our own marker, read back from storage: now it is durable
377
+ if @data_loss && doc.data_loss_at && doc.data_loss_at >= @data_loss.at.to_i
378
+ @data_loss_persisted = true
379
+ end
380
+
381
+ # a loss another process recorded is still a loss for this report
382
+ if @data_loss.nil? && doc.data_loss_at
383
+ @data_loss = DataLoss.new(at: Time.at(doc.data_loss_at),
384
+ kind: (doc.data_loss_kind || "eviction").to_sym,
385
+ detail: "recorded by another process")
386
+ end
387
+ @observed_tombstone_epoch = doc.tombstone_epoch
388
+ note_tombstones(doc)
389
+ doc
390
+ end
391
+
392
+ def note_tombstones(doc)
393
+ seen = @tombstones_seen || 0
394
+ return if doc.tombstone_epoch <= seen
395
+
396
+ fresh = doc.tombstone_keys_above(seen)
397
+ @tombstone_notifications = (@tombstone_notifications || []) | fresh
398
+ @tombstones_seen = doc.tombstone_epoch
399
+ end
400
+
401
+ # has to track what we have actually seen: a writer holding a stale 0 would
402
+ # stamp genuine new observations as pre-delete and have them filtered out
403
+ def observed_tombstone_epoch
404
+ @observed_tombstone_epoch || document.tombstone_epoch
405
+ end
406
+
407
+ def write(doc)
408
+ @observed_tombstone_epoch = doc.tombstone_epoch
409
+ result = @target.write(data_key, doc.to_json)
410
+ if result
411
+ @last_write_at = Time.now.to_i
412
+ @unwritten_since = nil
413
+ # it exists now, so a later absence is the backend dropping it rather
414
+ # than us never having written
415
+ @seen_document = true
416
+ end
417
+ result
418
+ end
419
+
420
+ ###
421
+ # Coverband writes deltas, not heartbeats, so a document that stops seeing
422
+ # new keys stops being written and ages out of a cache that expires by
423
+ # write age. Touching the document is safe -- an ordinary write the
424
+ # protocol already repairs. Touching the pointer is not.
425
+ ###
426
+ def keep_alive(doc)
427
+ return unless @keep_alive_after
428
+ return if doc.empty?
429
+
430
+ last = @last_write_at || doc.started_at
431
+ return unless last
432
+ return if (Time.now.to_i - last) < jittered_keep_alive
433
+
434
+ doc.record_watermark(@writer.writer_id, doc.watermark_for(@writer.writer_id),
435
+ host: @writer.host, pid: @writer.pid)
436
+ write(doc)
437
+ end
438
+
439
+ def jittered_keep_alive
440
+ @jittered_keep_alive ||= @keep_alive_after + rand(@keep_alive_after / 4)
441
+ end
442
+
443
+ ###
444
+ # The coordinator classifies pointer transitions. This consumer owns only
445
+ # the session consequences: drop incompatible work for a reset, report an
446
+ # orphan for pointer eviction, and carry safe initialization-race work into
447
+ # the authoritative generation.
448
+ ###
449
+ def handle_generation_change(change)
450
+ case change.cause
451
+ when GenerationChange::POINTER_EVICTION
452
+ record_loss(:orphaned_generation,
453
+ "pointer for #{@key_base} disappeared, generation #{change.previous_token} is orphaned")
454
+ drop_local_state!(change)
455
+ when GenerationChange::OPERATOR_RESET
456
+ drop_local_state!(change)
457
+ when GenerationChange::INITIALIZATION_RACE
458
+ log("lost a pointer initialization race for #{@key_base}, carrying #{@writer.pending_size} deltas forward")
459
+ @seen_document = false
460
+ @observed_tombstone_epoch = nil
461
+ @on_generation_change&.call(change)
462
+ when GenerationChange::DOCUMENT_EVICTION
463
+ @on_generation_change&.call(change)
464
+ end
465
+ end
466
+
467
+ def drop_local_state!(change)
468
+ @writer.rotate_identity!
469
+ @written_through = nil
470
+ @observed_tombstone_epoch = nil
471
+ @seen_document = false
472
+ # epochs restart at zero in a new generation, so a larger remembered one
473
+ # would ignore the new generation's first deletes
474
+ @tombstones_seen = nil
475
+ @tombstone_notifications = []
476
+ @on_generation_change&.call(change)
477
+ end
478
+
479
+ def operation(&block)
480
+ @generation_coordinator.operation(&block)
481
+ end
482
+
483
+ def data_key
484
+ @generation_coordinator.data_key
485
+ end
486
+
487
+ ###
488
+ # Only lost if it never reached a document. One already written and merely
489
+ # awaiting confirmation is in the payload; dropping it forfeits the repair
490
+ # if another writer clobbered that write -- a weaker claim than "results
491
+ # before this point are unavailable", and it has to read differently.
492
+ ###
493
+ def record_dropped(dropped)
494
+ return if dropped.empty?
495
+
496
+ if @written_through && dropped.all? { |delta| delta.seq <= @written_through }
497
+ record_loss(:unconfirmed_dropped,
498
+ "gave up the retry for #{dropped.length} deltas already written but never confirmed")
499
+ else
500
+ record_loss(:pending_dropped, "dropped #{dropped.length} deltas that outlived the retention caps")
501
+ end
502
+ end
503
+
504
+ def note_unwritten
505
+ @unwritten_since ||= Time.now.to_i
506
+ end
507
+
508
+ def record_loss(kind, detail)
509
+ @data_loss = DataLoss.new(at: Time.now, kind: kind, detail: detail)
510
+ @data_loss_persisted = false
511
+ log("data loss (#{kind}) for #{@key_base}: #{detail}")
512
+ end
513
+
514
+ ###
515
+ # Stamps the loss onto the document about to be written, without marking it
516
+ # persisted: the write can be refused or clobbered, and believing it landed
517
+ # would drop the marker for good. Confirmation comes from reading it back.
518
+ ###
519
+ def persist_data_loss(doc)
520
+ return if @data_loss.nil? || @data_loss_persisted
521
+
522
+ doc.data_loss_at = @data_loss.at
523
+ doc.data_loss_kind = @data_loss.kind
524
+ end
525
+ end
526
+ end
527
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "io_guard"
4
+
5
+ module Coverband
6
+ module Storage
7
+ ###
8
+ # The one place Coverband talks to an ActiveSupport::Cache store, so the
9
+ # storage I/O guard covers reads and deletes, not only writes.
10
+ #
11
+ # The target may be given lazily (a block or callable) because Rails.cache
12
+ # does not exist while config/coverband.rb is loading.
13
+ ###
14
+ class Target
15
+ # not a failure: Coverband.start runs from before_configuration, so a cycle
16
+ # can land before Rails assigns Rails.cache. Callers hold their work
17
+ Unavailable = Class.new(StandardError)
18
+
19
+ # the resolver handed back something that is not a cache store, which no
20
+ # later cycle can fix; worth saying once, loudly, rather than every cycle
21
+ # in the same words as the transient case
22
+ Misconfigured = Class.new(Unavailable)
23
+
24
+ # the calls the merge protocol makes; a resolver handing back something
25
+ # else is a configuration mistake worth naming
26
+ REQUIRED_METHODS = %i[read write delete].freeze
27
+
28
+ ATOMIC_CREATE_STORES = [
29
+ "ActiveSupport::Cache::RedisCacheStore",
30
+ "ActiveSupport::Cache::MemCacheStore",
31
+ "SolidCache::Store"
32
+ ].freeze
33
+
34
+ def initialize(target = nil, &block)
35
+ @resolver = block || target
36
+ @mutex = Mutex.new
37
+ @target = usable(target) unless target.respond_to?(:call)
38
+ end
39
+
40
+ # resolved once, on first use; the mutex makes that true when several
41
+ # threads report at once on boot. An unusable result is not memoized, so a
42
+ # later cycle can resolve it properly
43
+ def target
44
+ return @target if @target
45
+
46
+ @mutex.synchronize do
47
+ @target ||= usable(@resolver.respond_to?(:call) ? @resolver.call : @resolver)
48
+ end
49
+ return @target if @target
50
+ raise(@unusable ? Misconfigured : Unavailable, unavailable_message)
51
+ end
52
+
53
+ def read(key)
54
+ IOGuard.guard { target.read(key) }
55
+ end
56
+
57
+ def read_multi(*keys)
58
+ return {} if keys.empty?
59
+
60
+ IOGuard.guard do
61
+ if target.respond_to?(:read_multi)
62
+ target.read_multi(*keys)
63
+ else
64
+ keys.each_with_object({}) do |key, found|
65
+ value = target.read(key)
66
+ # frozen for the same reason as the Redis target's read_multi
67
+ found[key.frozen? ? key : key.dup.freeze] = value unless value.nil?
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # the store's own truthiness: a false write means "not durable" and callers
74
+ # have to retain their pending state, so it is never coerced
75
+ def write(key, value, options = {})
76
+ IOGuard.guard { target.write(key, value, {expires_in: nil}.merge(options)) }
77
+ end
78
+
79
+ # whether creation is atomic across processes, not whether the store
80
+ # accepts the option: MemoryStore and FileStore accept unless_exist
81
+ # without giving a usable guarantee
82
+ def atomic_create?
83
+ ATOMIC_CREATE_STORES.include?(target.class.name)
84
+ end
85
+
86
+ def create(key, value)
87
+ return write(key, value, expires_in: nil) unless atomic_create?
88
+
89
+ IOGuard.guard { target.write(key, value, unless_exist: true, expires_in: nil) }
90
+ end
91
+
92
+ def delete(key)
93
+ IOGuard.guard { target.delete(key) }
94
+ end
95
+
96
+ private
97
+
98
+ def usable(resolved)
99
+ return nil if resolved.nil?
100
+ return resolved if REQUIRED_METHODS.all? { |method| resolved.respond_to?(method) }
101
+
102
+ @unusable = resolved
103
+ nil
104
+ end
105
+
106
+ def unavailable_message
107
+ if @unusable
108
+ "the configured cache store (#{@unusable.class}) does not respond to " \
109
+ "#{REQUIRED_METHODS.join(", ")}, so Coverband cannot store coverage in it"
110
+ else
111
+ "the cache store is not available yet. Rails.cache is assigned after " \
112
+ "Coverband starts, so this resolves on a later reporting cycle"
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end