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.
- checksums.yaml +4 -4
- data/.github/workflows/diagram.yml +2 -2
- data/.github/workflows/main.yml +64 -7
- data/Gemfile +16 -0
- data/Gemfile.rails7.0 +12 -0
- data/Gemfile.rails7.1 +6 -1
- data/Gemfile.rails7.2 +5 -0
- data/Gemfile.rails8.0 +15 -0
- data/README.md +218 -12
- data/Rakefile +16 -5
- data/changes.md +60 -0
- data/coverband.gemspec +15 -13
- data/lib/coverband/adapters/active_support_cache_store.rb +54 -0
- data/lib/coverband/adapters/base.rb +42 -10
- data/lib/coverband/adapters/file_store.rb +2 -2
- data/lib/coverband/adapters/hash_redis_store.rb +22 -3
- data/lib/coverband/adapters/null_store.rb +0 -4
- data/lib/coverband/adapters/redis_cleanup.rb +101 -0
- data/lib/coverband/adapters/redis_store.rb +32 -65
- data/lib/coverband/adapters/session_coverage.rb +172 -0
- data/lib/coverband/adapters/stdout_store.rb +0 -4
- data/lib/coverband/adapters/tracker_storage/base.rb +79 -0
- data/lib/coverband/adapters/tracker_storage/document_repository.rb +49 -0
- data/lib/coverband/adapters/tracker_storage/factory.rb +57 -0
- data/lib/coverband/adapters/tracker_storage/redis_hash_repository.rb +127 -0
- data/lib/coverband/collectors/abstract_tracker.rb +135 -51
- data/lib/coverband/collectors/query_burst_tracker.rb +72 -19
- data/lib/coverband/collectors/route_tracker.rb +6 -0
- data/lib/coverband/collectors/tracker_registry.rb +63 -0
- data/lib/coverband/collectors/translation_tracker.rb +6 -0
- data/lib/coverband/collectors/view_tracker.rb +11 -5
- data/lib/coverband/configuration.rb +95 -131
- data/lib/coverband/mcp/http_handler.rb +1 -1
- data/lib/coverband/mcp/server.rb +4 -1
- data/lib/coverband/mcp/tools/get_storage_health.rb +28 -0
- data/lib/coverband/reporters/base.rb +0 -2
- data/lib/coverband/reporters/html_report.rb +2 -3
- data/lib/coverband/reporters/json_report.rb +2 -2
- data/lib/coverband/reporters/web.rb +72 -15
- data/lib/coverband/storage/document.rb +173 -0
- data/lib/coverband/storage/generation.rb +165 -0
- data/lib/coverband/storage/generation_change.rb +53 -0
- data/lib/coverband/storage/generation_coordinator.rb +145 -0
- data/lib/coverband/storage/io_guard.rb +29 -0
- data/lib/coverband/storage/read_fallback.rb +30 -0
- data/lib/coverband/storage/redis_target.rb +74 -0
- data/lib/coverband/storage/session.rb +527 -0
- data/lib/coverband/storage/target.rb +117 -0
- data/lib/coverband/storage/writer.rb +194 -0
- data/lib/coverband/storage_health.rb +90 -0
- data/lib/coverband/utils/configuration_template.rb +14 -1
- data/lib/coverband/utils/railtie.rb +2 -2
- data/lib/coverband/utils/result.rb +5 -5
- data/lib/coverband/utils/results.rb +32 -16
- data/lib/coverband/utils/source_file.rb +17 -27
- data/lib/coverband/utils/tasks.rb +71 -6
- data/lib/coverband/version.rb +1 -1
- data/lib/coverband.rb +98 -20
- data/public/application.css +540 -146
- data/test/benchmarks/benchmark.rake +13 -0
- data/test/coverband/adapters/active_support_cache_store_test.rb +613 -0
- data/test/coverband/adapters/capabilities_test.rb +80 -0
- data/test/coverband/adapters/redis_store_test.rb +63 -2
- data/test/coverband/adapters/solid_cache_store_test.rb +181 -0
- data/test/coverband/adapters/tracker_storage_test.rb +170 -0
- data/test/coverband/collectors/query_burst_tracker_test.rb +155 -10
- data/test/coverband/collectors/route_tracker_test.rb +2 -13
- data/test/coverband/collectors/tracker_registry_test.rb +218 -0
- data/test/coverband/collectors/translation_tracker_test.rb +76 -16
- data/test/coverband/collectors/view_tracker_test.rb +49 -12
- data/test/coverband/configuration_test.rb +36 -38
- data/test/coverband/mcp/server_test.rb +2 -1
- data/test/coverband/mcp/tools/get_storage_health_test.rb +41 -0
- data/test/coverband/reporters/html_test.rb +26 -0
- data/test/coverband/reporters/json_test.rb +53 -0
- data/test/coverband/reporters/web_test.rb +96 -0
- data/test/coverband/storage/generation_coordinator_test.rb +191 -0
- data/test/coverband/storage/io_guard_test.rb +127 -0
- data/test/coverband/storage/protocol_conformance_test.rb +725 -0
- data/test/coverband/storage_health_test.rb +99 -0
- data/test/coverband/track_key_test.rb +8 -0
- data/test/coverband/utils/cleanup_tasks_test.rb +188 -0
- data/test/coverband/utils/file_list_test.rb +2 -2
- data/test/coverband/utils/result_test.rb +4 -4
- data/test/coverband/utils/results_test.rb +100 -10
- data/test/coverband/utils/source_file_test.rb +40 -6
- data/test/forked/rails_cache_full_stack_test.rb +64 -0
- data/test/forked/rails_full_stack_test.rb +7 -0
- data/test/integration/full_stack_deferred_eager_test.rb +0 -1
- data/test/integration/full_stack_send_deferred_eager_test.rb +0 -1
- data/test/integration/full_stack_test.rb +0 -1
- data/test/integration/mcp_integration_test.rb +4 -2
- data/test/integration/standalone_json_report_test.rb +46 -0
- data/test/integration/without_redis_test.rb +76 -0
- data/test/rails7_dummy/config/application.rb +1 -0
- data/test/rails7_dummy/config/coverband_missing_redis.rb +0 -2
- data/test/rails7_dummy/config/coverband_rails_cache.rb +16 -0
- data/test/rails8_dummy/config/application.rb +1 -0
- data/test/rails8_dummy/config/coverband_rails_cache.rb +3 -0
- data/test/rails_test_helper.rb +2 -1
- data/test/test_helper.rb +5 -17
- data/views/file_list.erb +2 -2
- data/views/source_file.erb +4 -3
- metadata +48 -40
- data/lib/coverband/adapters/memcached_store.rb +0 -85
- data/lib/coverband/adapters/web_service_store.rb +0 -152
- data/lib/coverband/collectors/view_tracker_service.rb +0 -59
- data/test/coverband/adapters/memecached_store_test.rb +0 -26
- 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
|