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
data/changes.md CHANGED
@@ -1,3 +1,63 @@
1
+ ### 7.0.0
2
+
3
+ **Breaking changes**
4
+
5
+ * **Upgrading from 6.x? Follow [Upgrading to 7.0](https://github.com/danmayer/coverband#upgrading-to-70) in the README**, which lists what to change for each item below. Removed APIs are removed outright rather than deprecated first.
6
+ * **The `redis` gem is no longer a dependency of Coverband. If you use Redis (the default), add `gem "redis"` to your Gemfile when you upgrade.** Redis is still the default store and still needs no other configuration: whenever the `redis` gem is installed Coverband loads it and stores coverage there exactly as before. Apps that only got `redis` through Coverband will otherwise boot with a warning telling them to add it, and record nothing until they do. Apps that store coverage elsewhere (`ActiveSupportCacheStore` with Solid Cache, memcached, or files) no longer need Redis installed at all
7
+ * **Coverage and tracker history do not survive this upgrade.** Stored documents now carry the metadata the new merge protocol needs, and keys are scoped by a generation token, so the storage format version changed for both `RedisStore` and the cache adapters. Old keys are ignored rather than migrated, and coverage accumulates fresh from the upgrade. Delete the old `coverband_3_2.*` keys once you are happy with the new data.
8
+ * **Reporting to coverband.io (`WebServiceStore`) has been removed, as the hosted service is no longer maintained.** This also removes `ViewTrackerService`, loading `config/coverband_service.rb`, the `api_key`, `service_url`, `coverband_timeout`, `service_dev_mode`, `service_test_mode`, and `process_type` settings, and the `COVERBAND_API_KEY`, `COVERBAND_URL`, `COVERBAND_ENABLE_DEV_MODE`, `COVERBAND_ENABLE_TEST_MODE`, and `PROCESS_TYPE` environment variables. Setting `COVERBAND_API_KEY` no longer switches Coverband into service mode. Move settings into `config/coverband.rb` and configure Redis or an `ActiveSupportCacheStore`
9
+ * Removed configuration settings that no longer had production consumers: `reporter` (no effect since 5.0), `s3_region`, `s3_bucket`, `s3_access_key_id`, `s3_secret_access_key`, `track_gems`, `gem_details`, and `simulate_oneshot_lines_coverage`, plus the coverband.io settings above. Assigning any of them logs a warning naming the setting and how to fix it, and is otherwise ignored, so an old `config/coverband.rb` still boots.
10
+ * `Coverband::Utils::SourceFile` and `Coverband::Utils::Result` now accept only enriched file records from stored Coverband reports. Passing the legacy bare line-coverage Array is no longer supported.
11
+ * **`Coverband::Adapters::MemcachedStore` has been removed.** Use `ActiveSupportCacheStore.new(cache, cache_namespace: "...")` with the same memcached cache; it also supports the trackers. `ActiveSupportCacheStore` no longer accepts `memcached_namespace`
12
+ * **`background_reporting_sleep_seconds` now defaults to 600** for the new cache adapter (`HashRedisStore` still defaults to 300, everything else to 60), because each report rewrites a whole document.
13
+ * `Adapters::Base#size` now returns bytes as an Integer or `nil`; `size_in_mib` owns the "N/A" presentation. A store returning the string `"N/A"` previously reported `0.00` MiB.
14
+ * `Adapters::Base#get_coverage_report` now reports the receiver's coverage instead of routing through `Coverband.configuration.store`, which quietly returned another store's data when the receiver was not the configured one.
15
+ * Removed `AbstractTracker#redis_store`; custom trackers use `#storage`. Trackers no longer reach through the store to raw Redis commands.
16
+ * Removed the misspelled `Coverband::Utils::Results#runtime_relavent_lines`; use `#runtime_relevant_lines` instead.
17
+ * Removed `Adapters::Base#raw_store` and the Redis adapters' raw-client escape hatch. Redis maintenance now uses the narrow `store.cleanup` capability (`clear_legacy!` and `clear_orphans!`); adapters that cannot enumerate keys return `nil`.
18
+ * Work is retained when generation resolution fails, so a backend blip during a reporting cycle no longer discards that cycle's coverage. Previously only deltas that had reached the pending queue were protected, and the generation is resolved before the payload is enqueued. The retention caps apply to that held work too, so a sustained outage cannot grow the queue without limit
19
+ * A cache store that is not resolvable yet (`ActiveSupportCacheStore.new { Rails.cache }` running before Rails assigns `Rails.cache`) is no longer a `NoMethodError` on a nil target. It holds the cycle's work, logs, and resolves on a later cycle. A resolver returning something that is not a cache store now names the problem instead of failing deep in the storage layer
20
+ * Rake tasks that reach the store (`coverband:clear`, `clear_coverage`, `clear_tracker`, `clear_legacy`, `clear_orphans`, `coverage`, `coverage_json`, `coverage_html`) load the Rails environment first. With a lazily configured store they previously failed with an unavailable cache unless `rake environment` was named explicitly
21
+ * Coverage data loss (eviction, dropped pending deltas) is now surfaced on the web report index. It was logged and shown on the tracker tabs, but the coverage index reported partial numbers as though they were complete
22
+ * `Adapters::SessionCoverage#save_report` returns the protocol state from `Session#record` rather than `nil`, so a caller can tell a write that landed from one that was deferred or refused
23
+ * Coverage now absorbs the two-cycle outage the design commits to. The cap bounds the queue, and the cycle that recovers takes a slot in it, so a cap of 2 only ever absorbed one; it is now 3
24
+ * Data loss from dropping a delta that was already written and merely awaiting confirmation is reported as `unconfirmed_dropped` rather than `pending_dropped`, and the web report says the numbers may be undercounted instead of claiming earlier results are unavailable. Only the second kind is lost coverage
25
+ * Trackers keep their own keys whenever a report is not stored (`:failed`, `:unavailable`, or an error), so an unreachable backend is no longer lossier for them than one that raises. `TrackerStorage::Base#retains_pending?` is removed; the decision comes from what `record` returns
26
+ * A write refused or errored *after* the delta was enqueued now reports `:retained` rather than `:failed`, since the work is in the storage queue and a caller holding a second copy would replay it. This is the documented memcached >1MB path, where it double counted `QueryBurstTracker`
27
+ * `QueryBurstTracker` reports on a quiet cycle when storage is holding work it could not write, instead of waiting for the next burst to flush it
28
+ * A document that cannot be written is now reported while it is happening, rather than an hour later. Its retention caps never fire on a quiet tracker -- nothing new arrives for them to drop -- so only the absolute age cap eventually turned a permanently refused write (a tracker document past memcached's 1MB limit, for instance) into a loss event, and until then the tab read "0 used" exactly like an app that used none. The web report now says how many reports could not be stored and since when
29
+ * Presence trackers (views, routes, translations) re-supply their known keys when storage reports work dropped at its retention caps, so a backend outage longer than the queue no longer leaves a used view reading as unused. Their merge is idempotent, so this cannot double count; `QueryBurstTracker` sums, so its loss stays irreducible and reported
30
+ * `save_report` no longer mutates the report it is given. The merge used to sum stored counts back into the caller's line arrays, so a caller that reused its report object re-submitted counts that were already recorded.
31
+
32
+ **Features**
33
+
34
+ * Replaced the generation lifecycle mixin and its implicit instance-variable/hook contract with a state-owning coordinator and typed generation-change events (#656)
35
+ * Added structured storage health through `Coverband.storage_health`, normal JSON reports, and the MCP `get_storage_health` tool, so monitoring can distinguish healthy, stalled, data-loss, and unsupported diagnostic states (#658)
36
+ * Added `Coverband::Adapters::ActiveSupportCacheStore`, which stores coverage in any `ActiveSupport::Cache::Store` — Redis, Memcached, files, or Solid Cache for Postgres/MySQL/SQLite. The cache target can be passed lazily (`ActiveSupportCacheStore.new { Rails.cache }`) so it works from `config/coverband.rb`, where `Rails.cache` does not exist yet (#533)
37
+ * Trackers (views, routes, translations, query bursts) now work on non-Redis stores. They previously reached through the store to raw Redis commands, so file and memcached users got trackers that silently collected nothing and empty web UI tabs
38
+ * Fixed the read-modify-write conflict Coverband has always had on `RedisStore`: concurrent reports could silently drop one process's contribution. Reports are now applied under a per-writer sequence, so a conflicting write is detected and repaired on the next cycle without double counting. `QueryBurstTracker` had the same conflict on its cumulative counters and is fixed the same way
39
+ * Resets are now strong: `clear!` and `reset_recordings` retire a whole generation key, so a straggling write from before the reset cannot undo it
40
+ * Coverband now detects when a backend has dropped its data (cache eviction, `Rails.cache.clear`) and reports it in the web UI rather than silently showing partial numbers
41
+ * Trackers are skipped with an explanatory log line when the configured store cannot support them, instead of collecting nothing
42
+ * Query bursts no longer count Coverband's own storage queries against the surrounding request, which would otherwise inflate query counts on a database backed cache
43
+ * Added `persistent_coverage?`, `supports_trackers?`, and `supports_paged_reports?` capability predicates on adapters, plus `file_count` and `cached_file_count` defaults so non-Redis stores no longer raise from the web UI's datatables path
44
+ * `redis_ttl` applies to stored coverage documents again; generation pointers deliberately never expire, since a pointer that outlived its document would leave live coverage unreachable
45
+ * Storage reads degrade to empty and log when the backend is unavailable (an unreachable cache, or a Solid Cache table that has not been created yet) rather than raising into the request rendering the report; write failures still reach the reporting paths that log them
46
+ * Added `rake test:solid_cache`, an ENV gated SQLite backed Solid Cache suite covering atomic pointer creation, batched pointer reads, ActiveRecord being unavailable, a missing Solid Cache schema, and Coverband's own SQL not being attributed to the request that triggered the report
47
+ * Added `rake coverband:clear_orphans` to reclaim generation keys no longer referenced by a pointer, and scoped `coverband:clear_legacy` to Coverband's own namespaces and tracker names so it cannot delete unrelated application keys
48
+ * CI now runs a memcached service, so the memcached backed adapter is actually exercised
49
+ * Coverage adapters share one implementation of the merge protocol, and the generation lifecycle is shared between the session and the Redis hash repository, rather than each reimplementing the same bookkeeping
50
+ * The web report has a dark theme. It follows the operating system through `prefers-color-scheme`, so there is nothing to configure and no toggle; every color in `public/application.css` now resolves through a custom property on `:root`, which makes the report restylable from one place. Coverage row colors are picked for contrast on the dark surface rather than darkened mechanically, so covered / missed / never / skipped stay as easy to tell apart; thanks @naofumi-fujii (#665, #666, #667)
51
+ * Added `Coverband::Collectors::TrackerRegistry` so applications and gems can register their own trackers, along with `Coverband::Configuration.add_tracker_flag` for their enablement flags and `Coverband.configuration.tracker_for` / `Coverband.track_key` support for trackers without a dedicated accessor; bundled trackers now self-register and are initialized generically at Rails boot (#651)
52
+
53
+ **Bug fixes**
54
+
55
+ * Coverband's development dependencies no longer globally constrain JSON to 2.x. Legacy Rails test bundles retain their required JSON 2 constraint, while the latest-Rails suite explicitly validates Coverband against JSON 3. This constraint was development-only and never affected applications installing Coverband.
56
+ * The web report's `/report_json` route and `JSONReport.new(store, as_report: true)` no longer raise `NoMethodError` outside Rails, where ActiveSupport's `as_json` is not loaded; thanks @dajiaohuang (#669)
57
+ * The web report's password check accepts passwords containing `:` (it split the credentials on every colon) and compares in constant time, as does the MCP endpoint's bearer token check. This also drops the runtime dependency on the `base64` gem
58
+ * `Coverband::Utils::Results#respond_to?(name, true)` no longer raises `ArgumentError`
59
+ * The runtime % no longer reads 100% for files with runtime relevant lines that were never hit, and no longer divides by every line of a file that was never eager loaded. It only counts runtime hits on lines that eager loading did not cover, and takes line relevance from real coverage data rather than the never loaded placeholder, which marked blank and comment lines as relevant. Runtime relevant lines on the file page use the same calculation, and the runtime % there is colored by its own value rather than by % covered (#668)
60
+
1
61
  ### 6.2.2
2
62
 
3
63
  * Feature: Added a dead-code review session to the web report — hide files/folders by hovering any path segment, mark files for deletion with an optional comment, and export marked entries to CSV to drive an out-of-band code-removal process; session state lives in browser localStorage; thanks @obie (#647, #649)
data/coverband.gemspec CHANGED
@@ -48,21 +48,23 @@ Gem::Specification.new do |spec|
48
48
  spec.add_development_dependency "rake"
49
49
  spec.add_development_dependency "resque"
50
50
  spec.add_development_dependency "standard", ">= 1.35.1"
51
- # breaking changes in various rubocop versions
52
- spec.add_development_dependency "rubocop"
53
-
54
- spec.add_development_dependency "coveralls"
55
51
  # minitest-profile is not compatible with Rails 7.1.0 setup... dropping it for now
56
52
  # spec.add_development_dependency "minitest-profile"
57
53
  spec.add_development_dependency "webmock"
58
- spec.add_development_dependency "dalli" # Default memcached adapter
59
- spec.add_development_dependency "mcp" # For MCP server support testing
54
+ spec.add_development_dependency "dalli" # memcached client for the cache adapter tests
55
+ # the cache adapter duck types the ActiveSupport::Cache::Store interface and
56
+ # never requires it, but the tests need real cache stores to run against
57
+ spec.add_development_dependency "activesupport"
58
+ spec.add_development_dependency "mcp", ">= 0.23" # For MCP server support testing
59
+
60
+ # Redis is optional since 7.0: it stays the default store whenever the redis gem
61
+ # is installed, and the ActiveSupport cache adapter covers everything else.
62
+ spec.add_development_dependency "redis", ">= 3.0"
60
63
 
61
- # TODO: Remove when other production adapters exist
62
- # because the default configuration of redis store, we really do require
63
- # redis now. I was reluctant to add this, but until we offer another production
64
- # quality adapter, I think this is more honest about requirements and reduces confusion
65
- # without this there was a race condition on calling coverband configure before redis was loaded
66
- spec.add_runtime_dependency "redis", ">= 3.0"
67
- spec.add_runtime_dependency "base64"
64
+ spec.post_install_message = <<~MSG
65
+ Coverband 7.0 no longer depends on the redis gem. Redis is still the default
66
+ store, so if you use it add `gem "redis"` to your Gemfile. To store coverage
67
+ elsewhere (Solid Cache, memcached, files) see config.store in the README.
68
+ Coverage stored by Coverband 6.x is not carried over; see changes.md.
69
+ MSG
68
70
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../storage/target"
5
+ require_relative "../storage/session"
6
+ require_relative "tracker_storage/factory"
7
+ require_relative "session_coverage"
8
+
9
+ module Coverband
10
+ module Adapters
11
+ ###
12
+ # Stores coverage in any ActiveSupport::Cache::Store: Redis, Memcached,
13
+ # files, and through Solid Cache, Postgres, MySQL, and SQLite -- one adapter
14
+ # instead of one per backend. See docs/active_support_cache_adapter_plan.md.
15
+ #
16
+ # The cache target may be given lazily, because Rails.cache does not exist
17
+ # while config/coverband.rb is loading:
18
+ #
19
+ # config.store = Coverband::Adapters::ActiveSupportCacheStore.new { Rails.cache }
20
+ ###
21
+ class ActiveSupportCacheStore < Base
22
+ include SessionCoverage
23
+
24
+ # bumped from coverband_3_2: documents carry their metadata and keys are
25
+ # generation scoped, so old keys are ignored rather than migrated
26
+ STORAGE_FORMAT_VERSION = "coverband_cache_4_0"
27
+
28
+ attr_reader :cache_namespace
29
+
30
+ def initialize(cache = nil, opts = {}, &block)
31
+ super()
32
+ @target = Storage::Target.new(cache, &block)
33
+ @cache_namespace = opts[:cache_namespace] || opts[:redis_namespace] || Coverband.configuration.redis_namespace
34
+ @format_version = self.class::STORAGE_FORMAT_VERSION
35
+ @ttl = opts[:ttl]
36
+ end
37
+
38
+ def tracker_storage
39
+ @tracker_storage ||= TrackerStorage::Factory.cache(target: @target, namespace: @cache_namespace,
40
+ format_version: @format_version)
41
+ end
42
+
43
+ private
44
+
45
+ def storage_target
46
+ @target
47
+ end
48
+
49
+ def coverage_key_base(local_type)
50
+ [@format_version, @cache_namespace, "coverage", local_type].compact.join(".")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -35,30 +35,63 @@ module Coverband
35
35
  raise ABSTRACT_KEY
36
36
  end
37
37
 
38
+ # adapters return bytes or nil; presentation lives here, so a store
39
+ # returning the string "N/A" can't be truthy and reported as 0.00 MiB
38
40
  def size_in_mib
39
- if size
40
- format("%<size>.2f", size: (size.to_f / 2**20))
41
- else
42
- "N/A"
43
- end
41
+ bytes = size
42
+ return "N/A" unless bytes.is_a?(Numeric)
43
+
44
+ format("%<size>.2f", size: (bytes.to_f / 2**20))
45
+ end
46
+
47
+ # tracker storage, or nil when this store can't support it; trackers are
48
+ # then skipped rather than left silently collecting nothing
49
+ def tracker_storage
50
+ nil
51
+ end
52
+
53
+ def supports_trackers?
54
+ !tracker_storage.nil?
55
+ end
56
+
57
+ # paginated / single file report loading, HashRedisStore only
58
+ def supports_paged_reports?
59
+ false
60
+ end
61
+
62
+ # eligible for the core coverage contract: round trips and can be counted
63
+ def persistent_coverage?
64
+ false
65
+ end
66
+
67
+ def file_count
68
+ coverage(Coverband::RUNTIME_TYPE).keys.length
69
+ end
70
+
71
+ def cached_file_count
72
+ @cached_file_count ||= file_count
44
73
  end
45
74
 
46
75
  def save_report(_report)
47
76
  raise "abstract"
48
77
  end
49
78
 
79
+ # this adapter's coverage: it used to route through configuration.store,
80
+ # quietly reporting someone else's data when the receiver was not it
50
81
  def get_coverage_report(options = {})
51
82
  coverage_cache = {}
52
- data = Coverband.configuration.store.split_coverage(Coverband::TYPES, coverage_cache, options)
53
- data.merge(Coverband::MERGED_TYPE => Coverband.configuration.store.merged_coverage(Coverband::TYPES, coverage_cache))
83
+ data = split_coverage(Coverband::TYPES, coverage_cache, options)
84
+ data.merge(Coverband::MERGED_TYPE => merged_coverage(Coverband::TYPES, coverage_cache))
54
85
  end
55
86
 
56
87
  def covered_files
57
88
  coverage.keys || []
58
89
  end
59
90
 
60
- def raw_store
61
- raise ABSTRACT_KEY
91
+ # Optional maintenance capability. Redis-backed adapters return an object
92
+ # that can enumerate and safely clean Coverband-owned keys.
93
+ def cleanup
94
+ nil
62
95
  end
63
96
 
64
97
  protected
@@ -79,7 +112,6 @@ module Coverband
79
112
  Coverband::Utils::FileHasher.hash_file(file)
80
113
  end
81
114
 
82
- # TODO: modify to extend report inline?
83
115
  def expand_report(report)
84
116
  expanded = {}
85
117
  report_time = Time.now.to_i
@@ -69,8 +69,8 @@ module Coverband
69
69
  File.write(path, JSON.dump(data))
70
70
  end
71
71
 
72
- def raw_store
73
- raise NotImplementedError, "FileStore doesn't support raw_store"
72
+ def persistent_coverage?
73
+ true
74
74
  end
75
75
 
76
76
  private
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require_relative "tracker_storage/factory"
4
5
 
5
6
  module Coverband
6
7
  module Adapters
@@ -34,7 +35,7 @@ module Coverband
34
35
 
35
36
  def supported?
36
37
  Gem::Version.new(@redis.info["redis_version"]) >= Gem::Version.new("2.6.0")
37
- rescue Redis::CannotConnectError => e
38
+ rescue *Coverband.redis_connection_errors => e
38
39
  Coverband.configuration.logger.info "Redis is not available (#{e}), Coverband not configured"
39
40
  Coverband.configuration.logger.info "If this is a setup task like assets:precompile feel free to ignore"
40
41
  end
@@ -184,8 +185,26 @@ module Coverband
184
185
  @cached_file_count ||= file_count(Coverband::RUNTIME_TYPE)
185
186
  end
186
187
 
187
- def raw_store
188
- @redis
188
+ def cleanup
189
+ RedisCleanup.new(@redis, namespace: @redis_namespace)
190
+ end
191
+
192
+ def persistent_coverage?
193
+ true
194
+ end
195
+
196
+ ###
197
+ # Coverage storage here is unchanged: LUA scripts and per file keys avoid
198
+ # the merge conflict entirely. Tracker storage is shared across adapters,
199
+ # so this store picks it up like any other.
200
+ ###
201
+ def tracker_storage
202
+ @tracker_storage ||= TrackerStorage::Factory.redis(redis: @redis, namespace: @redis_namespace,
203
+ format_version: REDIS_STORAGE_FORMAT_VERSION)
204
+ end
205
+
206
+ def supports_paged_reports?
207
+ true
189
208
  end
190
209
 
191
210
  def size
@@ -29,10 +29,6 @@ module Coverband
29
29
  def save_report(report)
30
30
  # NOOP
31
31
  end
32
-
33
- def raw_store
34
- raise NotImplementedError, "NullStore doesn't support raw_store"
35
- end
36
32
  end
37
33
  end
38
34
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coverband
4
+ module Adapters
5
+ ###
6
+ # The narrow maintenance interface shared by Redis-backed adapters. Key
7
+ # discovery and deletion stay here so callers never need the raw client or
8
+ # knowledge of Coverband's Redis formats.
9
+ ###
10
+ class RedisCleanup
11
+ GRACE_SECONDS = 3600
12
+ TRACKERS = %w[ViewTracker RouteTracker TranslationTracker QueryBurstTracker].freeze
13
+
14
+ def initialize(redis, namespace: nil)
15
+ @redis = redis
16
+ @namespace = namespace
17
+ end
18
+
19
+ def clear_legacy!
20
+ patterns = legacy_formats.map { |format| "#{format}*" }
21
+ [@namespace, nil].uniq.each do |namespace|
22
+ TRACKERS.each do |tracker|
23
+ prefix = namespace ? "#{namespace}_#{tracker}" : tracker
24
+ patterns << "#{prefix}_tracker"
25
+ patterns << "#{prefix}_tracker_time"
26
+ end
27
+ end
28
+
29
+ delete_matching(patterns)
30
+ end
31
+
32
+ def clear_orphans!
33
+ orphan_formats.sum { |format| remove_orphans(format) }
34
+ end
35
+
36
+ private
37
+
38
+ ###
39
+ # A generation is garbage only while its pointer names something else.
40
+ # Rechecking immediately before deletion protects a generation made live
41
+ # by a concurrent reset. Young or uncertain generations are retained.
42
+ ###
43
+ def remove_orphans(format)
44
+ removed = 0
45
+
46
+ @redis.scan_each(match: "#{format}*.g*").to_a.uniq.each do |key|
47
+ base = key[/\A(.*)\.g[^.]*\z/, 1]
48
+ token = key[/\.g([^.]*)\z/, 1]
49
+ next unless base && token
50
+
51
+ pointer = read_pointer("#{base}.pointer")
52
+ next if pointer && pointer["token"] == token
53
+ next if recently_written?(key)
54
+
55
+ removed += @redis.del(key)
56
+ end
57
+
58
+ removed
59
+ end
60
+
61
+ def read_pointer(key)
62
+ raw = @redis.get(key)
63
+ raw ? JSON.parse(raw) : nil
64
+ rescue JSON::ParserError
65
+ nil
66
+ end
67
+
68
+ def recently_written?(key)
69
+ idle = @redis.object("idletime", key)
70
+ idle ? idle < GRACE_SECONDS : true
71
+ rescue
72
+ # If Redis cannot establish the age, retaining the key is safest.
73
+ true
74
+ end
75
+
76
+ def legacy_formats
77
+ %w[coverband_3_2 coverband_hash_3_2 coverband_hash_4_0] - current_formats
78
+ end
79
+
80
+ def current_formats
81
+ [
82
+ RedisStore::REDIS_STORAGE_FORMAT_VERSION,
83
+ HashRedisStore::REDIS_STORAGE_FORMAT_VERSION,
84
+ ActiveSupportCacheStore::STORAGE_FORMAT_VERSION
85
+ ].uniq
86
+ end
87
+
88
+ def orphan_formats
89
+ [
90
+ RedisStore::REDIS_STORAGE_FORMAT_VERSION,
91
+ HashRedisStore::REDIS_STORAGE_FORMAT_VERSION
92
+ ].uniq
93
+ end
94
+
95
+ def delete_matching(patterns)
96
+ keys = patterns.flat_map { |pattern| @redis.scan_each(match: pattern).to_a }.uniq
97
+ keys.any? ? @redis.del(*keys) : 0
98
+ end
99
+ end
100
+ end
101
+ end
@@ -1,17 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../storage/redis_target"
4
+ require_relative "../storage/session"
5
+ require_relative "tracker_storage/factory"
6
+ require_relative "session_coverage"
7
+
3
8
  module Coverband
4
9
  module Adapters
5
10
  ###
6
11
  # RedisStore store a merged coverage file to redis
7
12
  ###
8
13
  class RedisStore < Base
14
+ include SessionCoverage
15
+
9
16
  ###
10
17
  # This key isn't related to the coverband version, but to the internal format
11
18
  # used to store data to redis. It is changed only when breaking changes to our
12
19
  # redis format are required.
20
+ #
21
+ # Bumped from coverband_3_2 for 7.0: coverage documents now carry the
22
+ # metadata the merge protocol needs, and keys are generation scoped. There
23
+ # is no migration; old keys are ignored.
13
24
  ###
14
- REDIS_STORAGE_FORMAT_VERSION = "coverband_3_2"
25
+ REDIS_STORAGE_FORMAT_VERSION = "coverband_5_0"
15
26
 
16
27
  attr_reader :redis_namespace
17
28
 
@@ -21,85 +32,41 @@ module Coverband
21
32
  @ttl = opts[:ttl]
22
33
  @redis_namespace = opts[:redis_namespace]
23
34
  @format_version = REDIS_STORAGE_FORMAT_VERSION
24
- @keys = {}
25
- Coverband::TYPES.each do |type|
26
- @keys[type] = [@format_version, @redis_namespace, type].compact.join(".")
27
- end
28
- end
29
-
30
- def clear!
31
- Coverband::TYPES.each do |type|
32
- @redis.del(type_base_key(type))
33
- end
34
- end
35
-
36
- def clear_file!(filename)
37
- Coverband::TYPES.each do |type|
38
- data = coverage(type)
39
- data.delete(filename)
40
- save_coverage(data, type)
41
- end
42
- end
43
-
44
- def size
45
- @redis.get(base_key) ? @redis.get(base_key).bytesize : "N/A"
46
- end
47
-
48
- def type=(type)
49
- super
50
- reset_base_key
51
35
  end
52
36
 
53
- def coverage(local_type = nil, opts = {})
54
- local_type ||= opts.key?(:override_type) ? opts[:override_type] : type
55
- data = redis.get type_base_key(local_type)
56
- data = data ? JSON.parse(data) : {}
57
- data.delete_if { |file_path, file_data| file_hash(file_path) != file_data["file_hash"] } unless opts[:skip_hash_check]
58
- data
37
+ def tracker_storage
38
+ sync_target
39
+ @tracker_storage ||= TrackerStorage::Factory.redis(redis: @redis, namespace: @redis_namespace,
40
+ format_version: @format_version)
59
41
  end
60
42
 
61
- # Note: This could lead to slight race on redis
62
- # where multiple processes pull the old coverage and add to it then push
63
- # the Coverband 2 had the same issue,
64
- # and the tradeoff has always been acceptable
65
- def save_report(report)
66
- data = merge_reports(report, coverage(nil, skip_hash_check: true))
67
- save_coverage(data)
68
- end
69
-
70
- def raw_store
71
- @redis
72
- end
73
-
74
- def file_count
75
- data = redis.get type_base_key(Coverband::RUNTIME_TYPE)
76
- JSON.parse(data).keys.length
77
- end
78
-
79
- def cached_file_count
80
- @cached_file_count ||= file_count
43
+ def cleanup
44
+ RedisCleanup.new(@redis, namespace: @redis_namespace)
81
45
  end
82
46
 
83
47
  private
84
48
 
85
49
  attr_reader :redis
86
50
 
87
- def reset_base_key
88
- @base_key = nil
89
- end
51
+ ###
52
+ # The client can be swapped after construction, and a different client is
53
+ # a different database: cached generation tokens and unconfirmed deltas
54
+ # from the old one would be meaningless against the new one.
55
+ ###
56
+ def sync_target
57
+ return @target if @target && @target.redis.equal?(@redis)
90
58
 
91
- def base_key
92
- @base_key ||= [@format_version, @redis_namespace, type].compact.join(".")
59
+ sessions.clear
60
+ @tracker_storage = nil
61
+ @target = Storage::RedisTarget.new(@redis, ttl: @ttl)
93
62
  end
94
63
 
95
- def type_base_key(local_type)
96
- @keys[local_type]
64
+ def storage_target
65
+ sync_target
97
66
  end
98
67
 
99
- def save_coverage(data, local_type = nil)
100
- local_type ||= type
101
- redis.set type_base_key(local_type), data.to_json
102
- redis.expire(type_base_key(local_type), @ttl) if @ttl
68
+ def coverage_key_base(local_type)
69
+ [@format_version, @redis_namespace, "coverage", local_type].compact.join(".")
103
70
  end
104
71
  end
105
72
  end