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,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../storage/session"
|
|
4
|
+
|
|
5
|
+
module Coverband
|
|
6
|
+
module Adapters
|
|
7
|
+
###
|
|
8
|
+
# Coverage on top of the merge protocol, shared by the adapters that keep it
|
|
9
|
+
# in one document per type.
|
|
10
|
+
#
|
|
11
|
+
# Counts are additive, so a dropped write cannot be repaired by writing
|
|
12
|
+
# again: re-applying a sum double counts. The per writer sequences in
|
|
13
|
+
# Storage::Session are what make the retry safe, which is why both adapters
|
|
14
|
+
# go through here rather than each merging on their own.
|
|
15
|
+
#
|
|
16
|
+
# Including classes provide #storage_target and #coverage_key_base.
|
|
17
|
+
###
|
|
18
|
+
module SessionCoverage
|
|
19
|
+
###
|
|
20
|
+
# Coverage deltas are large, so they get a tighter bound than the trackers
|
|
21
|
+
# do. The cap counts the queue, not missed cycles: the cycle that recovers
|
|
22
|
+
# carries its own delta, so it takes a slot too, and the outage a store
|
|
23
|
+
# can absorb is one less than this.
|
|
24
|
+
###
|
|
25
|
+
COVERAGE_MAX_ENTRIES = 3
|
|
26
|
+
|
|
27
|
+
def persistent_coverage?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# the pointers this store will consult, so a cycle can fetch them in one
|
|
32
|
+
# round trip instead of one small read per document
|
|
33
|
+
def pointer_sessions
|
|
34
|
+
Coverband::TYPES.map { |session_type| build_session(session_type) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def prefetch_pointers!(extra_sessions = [])
|
|
38
|
+
Storage::Session.prefetch_pointers(storage_target, pointer_sessions + Array(extra_sessions))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# whether every type actually cleared: a reset whose pointer write did not
|
|
42
|
+
# land leaves the old generation authoritative, and callers have to be able
|
|
43
|
+
# to say so rather than report success
|
|
44
|
+
def clear!
|
|
45
|
+
results = Coverband::TYPES.map { |type| session_for(type).reset }
|
|
46
|
+
@cached_file_count = nil
|
|
47
|
+
results.all?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear_file!(filename)
|
|
51
|
+
relative = Utils::RelativeFileConverter.convert(filename)
|
|
52
|
+
Coverband::TYPES.each do |type|
|
|
53
|
+
session_for(type).delete_entry(relative)
|
|
54
|
+
session_for(type).delete_entry(filename)
|
|
55
|
+
end
|
|
56
|
+
@cached_file_count = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def size
|
|
60
|
+
session_for(type).stored_size
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def type=(type)
|
|
64
|
+
super
|
|
65
|
+
@cached_file_count = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def coverage(local_type = nil, opts = {})
|
|
69
|
+
local_type ||= opts.key?(:override_type) ? opts[:override_type] : type
|
|
70
|
+
data = session_for(local_type).entries.dup
|
|
71
|
+
unless opts[:skip_hash_check]
|
|
72
|
+
data.delete_if { |file_path, file_data| file_hash(file_path) != file_data["file_hash"] }
|
|
73
|
+
end
|
|
74
|
+
data
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# expanded here once: re-expanding on a retry would hand an old delta a
|
|
78
|
+
# fresh timestamp and let it slip past a tombstone recorded in between
|
|
79
|
+
# returns Session#record's protocol state, so a caller can tell a write
|
|
80
|
+
# that landed from one that was deferred or refused
|
|
81
|
+
def save_report(report)
|
|
82
|
+
result = session_for(type).record(own_expanded_report(report))
|
|
83
|
+
@cached_file_count = nil
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def save_coverage(report, local_type = nil)
|
|
88
|
+
session_for(local_type || type).record(own_expanded_report(report))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def file_count
|
|
92
|
+
coverage(Coverband::RUNTIME_TYPE, skip_hash_check: true).keys.length
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def data_loss
|
|
96
|
+
Coverband::TYPES.map { |type| session_for(type).data_loss }.compact.first
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# work held because it cannot be written, which for a document that can
|
|
100
|
+
# never be written is the only signal there is
|
|
101
|
+
def unwritten
|
|
102
|
+
Coverband::TYPES.map { |type| session_for(type).unwritten }.compact.first
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# gives up the repair those held reports would have done, in exchange for
|
|
106
|
+
# the memory; benchmarks and shutdown only
|
|
107
|
+
def discard_pending!
|
|
108
|
+
Coverband::TYPES.each { |type| session_for(type).discard_pending! }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
###
|
|
114
|
+
# A delta has to survive being applied, since re-applying it is how a lost
|
|
115
|
+
# update is repaired. expand_report hands over the caller's own line arrays
|
|
116
|
+
# and array_add sums into them in place unless frozen, so an unfrozen
|
|
117
|
+
# payload is overwritten with the merged document total on its first apply
|
|
118
|
+
# -- and a repair after that carries the whole document back in, doubling
|
|
119
|
+
# it once per repair. Freezing selects array_add's copying branch.
|
|
120
|
+
###
|
|
121
|
+
def own_expanded_report(report)
|
|
122
|
+
expand_report(report).each_value do |entry|
|
|
123
|
+
entry[Base::DATA_KEY] = entry[Base::DATA_KEY].dup.freeze
|
|
124
|
+
entry.freeze
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def sessions
|
|
129
|
+
@sessions ||= {}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# both types are built together, or reporting one would allocate the
|
|
133
|
+
# other's keys later at an unpredictable moment
|
|
134
|
+
def session_for(local_type)
|
|
135
|
+
local_type ||= type
|
|
136
|
+
Coverband::TYPES.each { |session_type| build_session(session_type) }
|
|
137
|
+
build_session(local_type)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_session(local_type)
|
|
141
|
+
sessions[local_type] ||= Storage::Session.new(
|
|
142
|
+
target: storage_target,
|
|
143
|
+
key_base: coverage_key_base(local_type),
|
|
144
|
+
merger: coverage_merger,
|
|
145
|
+
logger: Coverband.configuration.logger,
|
|
146
|
+
max_entries: COVERAGE_MAX_ENTRIES,
|
|
147
|
+
grace_seconds: grace_seconds,
|
|
148
|
+
# Delta has already advanced its baseline by the time a report is
|
|
149
|
+
# handed over, so nothing else is holding this cycle's work
|
|
150
|
+
retain_on_failure: true
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# line hits sum, so this merge is not idempotent and the applied sequence
|
|
155
|
+
# guard is what keeps a retry from inflating the counts
|
|
156
|
+
def coverage_merger
|
|
157
|
+
@coverage_merger ||= lambda do |doc, delta|
|
|
158
|
+
incoming = delta.payload.reject do |file, _data|
|
|
159
|
+
doc.tombstoned?(file, delta.tombstone_epoch)
|
|
160
|
+
end
|
|
161
|
+
doc.payload.replace(merge_reports(incoming.dup, doc.payload, skip_expansion: true))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def grace_seconds
|
|
166
|
+
Coverband.configuration.background_reporting_sleep_seconds * 2
|
|
167
|
+
rescue
|
|
168
|
+
1200
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coverband
|
|
4
|
+
module Adapters
|
|
5
|
+
module TrackerStorage
|
|
6
|
+
###
|
|
7
|
+
# What a tracker sees. Physical keys, generations, serialization, and the
|
|
8
|
+
# applied sequence protocol all live below this line; trackers deal in
|
|
9
|
+
# keys and timestamps.
|
|
10
|
+
#
|
|
11
|
+
# record returns a protocol state, not an outcome: "written" and "proven
|
|
12
|
+
# durable" differ, and that difference is the point of the watermark.
|
|
13
|
+
#
|
|
14
|
+
# :written_unconfirmed merged and written, still awaiting confirmation
|
|
15
|
+
# :confirmed an earlier write was proven durable and dropped
|
|
16
|
+
# :deferred enqueued only, nothing flushed this call
|
|
17
|
+
# :retained taken, but not durable yet; still ours to retry
|
|
18
|
+
# :failed refused before we took it, so it is yours again
|
|
19
|
+
# :unavailable the backend cannot be reached at all, same
|
|
20
|
+
#
|
|
21
|
+
# Only the last two mean the caller still owns the work. Keeping a copy
|
|
22
|
+
# against any of the others gives the same delta two owners, and both
|
|
23
|
+
# replay it.
|
|
24
|
+
###
|
|
25
|
+
class Base
|
|
26
|
+
ABSTRACT_KEY = "abstract"
|
|
27
|
+
|
|
28
|
+
def entries
|
|
29
|
+
raise ABSTRACT_KEY
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def record(_delta)
|
|
33
|
+
raise ABSTRACT_KEY
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete_entry(_key)
|
|
37
|
+
raise ABSTRACT_KEY
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reset
|
|
41
|
+
raise ABSTRACT_KEY
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def tracking_since
|
|
45
|
+
raise ABSTRACT_KEY
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def generation
|
|
49
|
+
raise ABSTRACT_KEY
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def data_loss
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# work held because it could not be written; a layout with no queue of
|
|
57
|
+
# its own has none to report
|
|
58
|
+
def unwritten
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def pending_size
|
|
63
|
+
0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# the generation pointer holder, when this repository has one to batch
|
|
67
|
+
def pointer_session
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# keys another process deleted since the last read, so trackers can drop
|
|
72
|
+
# them from their permanent local dedupe and record them again if used
|
|
73
|
+
def newly_tombstoned
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "../../storage/session"
|
|
6
|
+
|
|
7
|
+
module Coverband
|
|
8
|
+
module Adapters
|
|
9
|
+
module TrackerStorage
|
|
10
|
+
###
|
|
11
|
+
# A tracker stored as one document: metadata and payload written together
|
|
12
|
+
# and reverted together. Every cache backed tracker, plus the additive ones
|
|
13
|
+
# on Redis. The tracker supplies the merge, since only it knows whether two
|
|
14
|
+
# values combine by later timestamp or by summing.
|
|
15
|
+
###
|
|
16
|
+
class DocumentRepository < Base
|
|
17
|
+
extend Forwardable
|
|
18
|
+
|
|
19
|
+
# the session owns the protocol; this decides what a tracker may ask for
|
|
20
|
+
def_delegators :@session, :record, :delete_entry, :reset, :tracking_since,
|
|
21
|
+
:data_loss, :unwritten, :pending_size, :newly_tombstoned
|
|
22
|
+
def_delegator :@session, :generation_token, :generation
|
|
23
|
+
|
|
24
|
+
def initialize(target:, key_base:, merger:, logger: nil, on_generation_change: nil, session_options: {})
|
|
25
|
+
@session = Storage::Session.new(
|
|
26
|
+
target: target,
|
|
27
|
+
key_base: key_base,
|
|
28
|
+
merger: merger,
|
|
29
|
+
logger: logger,
|
|
30
|
+
on_generation_change: on_generation_change,
|
|
31
|
+
**session_options
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def entries
|
|
36
|
+
# Redis hgetall hands back strings and JSON parsing does not, so the
|
|
37
|
+
# two layouts would otherwise not be interchangeable for callers
|
|
38
|
+
@session.entries.each_with_object({}) do |(key, value), normalized|
|
|
39
|
+
normalized[key.to_s] = value.is_a?(String) ? value : value.to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def pointer_session
|
|
44
|
+
@session
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "document_repository"
|
|
4
|
+
require_relative "redis_hash_repository"
|
|
5
|
+
require_relative "../../storage/redis_target"
|
|
6
|
+
|
|
7
|
+
module Coverband
|
|
8
|
+
module Adapters
|
|
9
|
+
module TrackerStorage
|
|
10
|
+
###
|
|
11
|
+
# Builds tracker repositories, choosing the layout by merge semantics
|
|
12
|
+
# rather than by backend.
|
|
13
|
+
#
|
|
14
|
+
# Idempotent presence trackers keep native Redis hashes where the backend
|
|
15
|
+
# has them: a field written by one process is never reverted by another's
|
|
16
|
+
# write, which no whole document protocol can match.
|
|
17
|
+
#
|
|
18
|
+
# Everything else is a document. Additive trackers can never use the hash,
|
|
19
|
+
# because a hash co-writes metadata without co-reverting it: a stale
|
|
20
|
+
# multi-field HSET leaves another writer's field in place while wiping the
|
|
21
|
+
# metadata that recorded it, so that writer applies its delta twice. On a
|
|
22
|
+
# cache, idempotent trackers are documents too, and still need applied
|
|
23
|
+
# sequences -- not for retry safety, but to notice a stale write erased a
|
|
24
|
+
# key, which a permanent local dedupe would otherwise never re-enqueue.
|
|
25
|
+
###
|
|
26
|
+
class Factory
|
|
27
|
+
def self.redis(redis:, namespace:, format_version:)
|
|
28
|
+
new(target: Storage::RedisTarget.new(redis), namespace: namespace,
|
|
29
|
+
format_version: format_version, native_hashes: true)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.cache(target:, namespace:, format_version:)
|
|
33
|
+
new(target: target, namespace: namespace, format_version: format_version)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(target:, namespace:, format_version:, native_hashes: false)
|
|
37
|
+
@target = target
|
|
38
|
+
@namespace = namespace
|
|
39
|
+
@format_version = format_version
|
|
40
|
+
@native_hashes = native_hashes
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def for(name, merger:, idempotent: true, logger: nil, on_generation_change: nil)
|
|
44
|
+
key_base = [@format_version, @namespace, "tracker", name].compact.join(".")
|
|
45
|
+
|
|
46
|
+
if @native_hashes && idempotent
|
|
47
|
+
RedisHashRepository.new(target: @target, key_base: key_base,
|
|
48
|
+
logger: logger, on_generation_change: on_generation_change)
|
|
49
|
+
else
|
|
50
|
+
DocumentRepository.new(target: @target, key_base: key_base, merger: merger,
|
|
51
|
+
logger: logger, on_generation_change: on_generation_change)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../../storage/generation_coordinator"
|
|
5
|
+
require_relative "../../storage/read_fallback"
|
|
6
|
+
|
|
7
|
+
module Coverband
|
|
8
|
+
module Adapters
|
|
9
|
+
module TrackerStorage
|
|
10
|
+
###
|
|
11
|
+
# Presence trackers on Redis, kept on native hash operations.
|
|
12
|
+
#
|
|
13
|
+
# Their merge is max(timestamp), so re-applying a field is a no-op and a
|
|
14
|
+
# field written by one process is never reverted by another's write --
|
|
15
|
+
# strictly stronger than any whole document protocol, so these keep HSET
|
|
16
|
+
# and HDEL. What HDEL cannot do is stop a delete being undone by an HSET
|
|
17
|
+
# queued before it, which is why single key clears are best effort.
|
|
18
|
+
###
|
|
19
|
+
class RedisHashRepository < Base
|
|
20
|
+
include Storage::ReadFallback
|
|
21
|
+
|
|
22
|
+
STARTED_AT_FIELD = "__coverband_started_at__"
|
|
23
|
+
|
|
24
|
+
def initialize(target:, key_base:, logger: nil, grace_seconds: 1200, on_generation_change: nil)
|
|
25
|
+
@target = target
|
|
26
|
+
@logger = logger
|
|
27
|
+
@on_generation_change = on_generation_change
|
|
28
|
+
@generation_coordinator = Storage::GenerationCoordinator.new(
|
|
29
|
+
target: target,
|
|
30
|
+
key_base: key_base,
|
|
31
|
+
grace_seconds: grace_seconds,
|
|
32
|
+
on_change: ->(change) { handle_generation_change(change) }
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pointer_session
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# no pending deltas here, but a primed pointer is still cycle state
|
|
41
|
+
def discard_pending!
|
|
42
|
+
@generation_coordinator.discard_primed_pointer!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def pointer_key
|
|
46
|
+
@generation_coordinator.pointer_key
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def prime_pointer(pointer)
|
|
50
|
+
@generation_coordinator.prime_pointer(pointer)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def generation
|
|
54
|
+
safely { @generation_coordinator.token }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def entries
|
|
58
|
+
safely({}) do
|
|
59
|
+
operation do
|
|
60
|
+
@target.hgetall(data_key).reject { |field, _value| field == STARTED_AT_FIELD }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def record(delta)
|
|
66
|
+
return :deferred if delta.empty?
|
|
67
|
+
|
|
68
|
+
operation do
|
|
69
|
+
values = delta.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value.to_s }
|
|
70
|
+
values[STARTED_AT_FIELD] = Time.now.to_i.to_s unless started_at_set?
|
|
71
|
+
@target.hset(data_key, values)
|
|
72
|
+
@started_at_set = true
|
|
73
|
+
:written_unconfirmed
|
|
74
|
+
end
|
|
75
|
+
rescue => e
|
|
76
|
+
@logger&.error("Coverband: #{self.class.name} failed to store, error #{e.class.name} info #{e.message}")
|
|
77
|
+
:failed
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# best effort: a key observed by another process just before the delete
|
|
81
|
+
# can come back with no post-delete observation behind it
|
|
82
|
+
def delete_entry(key)
|
|
83
|
+
operation do
|
|
84
|
+
@target.hdel(data_key, key.to_s)
|
|
85
|
+
:written_unconfirmed
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reset
|
|
90
|
+
@generation_coordinator.reset
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def tracking_since
|
|
94
|
+
safely do
|
|
95
|
+
operation do
|
|
96
|
+
value = @target.hgetall(data_key)[STARTED_AT_FIELD]
|
|
97
|
+
value ? Time.at(value.to_i) : nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def started_at_set?
|
|
105
|
+
@started_at_set ||= !@target.hgetall(data_key)[STARTED_AT_FIELD].nil?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def handle_generation_change(change)
|
|
109
|
+
return unless [Storage::GenerationChange::OPERATOR_RESET,
|
|
110
|
+
Storage::GenerationChange::POINTER_EVICTION,
|
|
111
|
+
Storage::GenerationChange::INITIALIZATION_RACE].include?(change.cause)
|
|
112
|
+
|
|
113
|
+
@started_at_set = false
|
|
114
|
+
@on_generation_change&.call(change)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def operation(&block)
|
|
118
|
+
@generation_coordinator.operation(&block)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def data_key
|
|
122
|
+
@generation_coordinator.data_key
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|