coverband 6.2.2 → 7.0.0.rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/diagram.yml +2 -2
  3. data/.github/workflows/main.yml +64 -7
  4. data/Gemfile +16 -0
  5. data/Gemfile.rails7.0 +12 -0
  6. data/Gemfile.rails7.1 +6 -1
  7. data/Gemfile.rails7.2 +5 -0
  8. data/Gemfile.rails8.0 +15 -0
  9. data/README.md +218 -12
  10. data/Rakefile +16 -5
  11. data/changes.md +60 -0
  12. data/coverband.gemspec +15 -13
  13. data/lib/coverband/adapters/active_support_cache_store.rb +54 -0
  14. data/lib/coverband/adapters/base.rb +42 -10
  15. data/lib/coverband/adapters/file_store.rb +2 -2
  16. data/lib/coverband/adapters/hash_redis_store.rb +22 -3
  17. data/lib/coverband/adapters/null_store.rb +0 -4
  18. data/lib/coverband/adapters/redis_cleanup.rb +101 -0
  19. data/lib/coverband/adapters/redis_store.rb +32 -65
  20. data/lib/coverband/adapters/session_coverage.rb +172 -0
  21. data/lib/coverband/adapters/stdout_store.rb +0 -4
  22. data/lib/coverband/adapters/tracker_storage/base.rb +79 -0
  23. data/lib/coverband/adapters/tracker_storage/document_repository.rb +49 -0
  24. data/lib/coverband/adapters/tracker_storage/factory.rb +57 -0
  25. data/lib/coverband/adapters/tracker_storage/redis_hash_repository.rb +127 -0
  26. data/lib/coverband/collectors/abstract_tracker.rb +135 -51
  27. data/lib/coverband/collectors/query_burst_tracker.rb +72 -19
  28. data/lib/coverband/collectors/route_tracker.rb +6 -0
  29. data/lib/coverband/collectors/tracker_registry.rb +63 -0
  30. data/lib/coverband/collectors/translation_tracker.rb +6 -0
  31. data/lib/coverband/collectors/view_tracker.rb +11 -5
  32. data/lib/coverband/configuration.rb +95 -131
  33. data/lib/coverband/mcp/http_handler.rb +1 -1
  34. data/lib/coverband/mcp/server.rb +4 -1
  35. data/lib/coverband/mcp/tools/get_storage_health.rb +28 -0
  36. data/lib/coverband/reporters/base.rb +0 -2
  37. data/lib/coverband/reporters/html_report.rb +2 -3
  38. data/lib/coverband/reporters/json_report.rb +2 -2
  39. data/lib/coverband/reporters/web.rb +72 -15
  40. data/lib/coverband/storage/document.rb +173 -0
  41. data/lib/coverband/storage/generation.rb +165 -0
  42. data/lib/coverband/storage/generation_change.rb +53 -0
  43. data/lib/coverband/storage/generation_coordinator.rb +145 -0
  44. data/lib/coverband/storage/io_guard.rb +29 -0
  45. data/lib/coverband/storage/read_fallback.rb +30 -0
  46. data/lib/coverband/storage/redis_target.rb +74 -0
  47. data/lib/coverband/storage/session.rb +527 -0
  48. data/lib/coverband/storage/target.rb +117 -0
  49. data/lib/coverband/storage/writer.rb +194 -0
  50. data/lib/coverband/storage_health.rb +90 -0
  51. data/lib/coverband/utils/configuration_template.rb +14 -1
  52. data/lib/coverband/utils/railtie.rb +2 -2
  53. data/lib/coverband/utils/result.rb +5 -5
  54. data/lib/coverband/utils/results.rb +32 -16
  55. data/lib/coverband/utils/source_file.rb +17 -27
  56. data/lib/coverband/utils/tasks.rb +71 -6
  57. data/lib/coverband/version.rb +1 -1
  58. data/lib/coverband.rb +98 -20
  59. data/public/application.css +540 -146
  60. data/test/benchmarks/benchmark.rake +13 -0
  61. data/test/coverband/adapters/active_support_cache_store_test.rb +613 -0
  62. data/test/coverband/adapters/capabilities_test.rb +80 -0
  63. data/test/coverband/adapters/redis_store_test.rb +63 -2
  64. data/test/coverband/adapters/solid_cache_store_test.rb +181 -0
  65. data/test/coverband/adapters/tracker_storage_test.rb +170 -0
  66. data/test/coverband/collectors/query_burst_tracker_test.rb +155 -10
  67. data/test/coverband/collectors/route_tracker_test.rb +2 -13
  68. data/test/coverband/collectors/tracker_registry_test.rb +218 -0
  69. data/test/coverband/collectors/translation_tracker_test.rb +76 -16
  70. data/test/coverband/collectors/view_tracker_test.rb +49 -12
  71. data/test/coverband/configuration_test.rb +36 -38
  72. data/test/coverband/mcp/server_test.rb +2 -1
  73. data/test/coverband/mcp/tools/get_storage_health_test.rb +41 -0
  74. data/test/coverband/reporters/html_test.rb +26 -0
  75. data/test/coverband/reporters/json_test.rb +53 -0
  76. data/test/coverband/reporters/web_test.rb +96 -0
  77. data/test/coverband/storage/generation_coordinator_test.rb +191 -0
  78. data/test/coverband/storage/io_guard_test.rb +127 -0
  79. data/test/coverband/storage/protocol_conformance_test.rb +725 -0
  80. data/test/coverband/storage_health_test.rb +99 -0
  81. data/test/coverband/track_key_test.rb +8 -0
  82. data/test/coverband/utils/cleanup_tasks_test.rb +188 -0
  83. data/test/coverband/utils/file_list_test.rb +2 -2
  84. data/test/coverband/utils/result_test.rb +4 -4
  85. data/test/coverband/utils/results_test.rb +100 -10
  86. data/test/coverband/utils/source_file_test.rb +40 -6
  87. data/test/forked/rails_cache_full_stack_test.rb +64 -0
  88. data/test/forked/rails_full_stack_test.rb +7 -0
  89. data/test/integration/full_stack_deferred_eager_test.rb +0 -1
  90. data/test/integration/full_stack_send_deferred_eager_test.rb +0 -1
  91. data/test/integration/full_stack_test.rb +0 -1
  92. data/test/integration/mcp_integration_test.rb +4 -2
  93. data/test/integration/standalone_json_report_test.rb +46 -0
  94. data/test/integration/without_redis_test.rb +76 -0
  95. data/test/rails7_dummy/config/application.rb +1 -0
  96. data/test/rails7_dummy/config/coverband_missing_redis.rb +0 -2
  97. data/test/rails7_dummy/config/coverband_rails_cache.rb +16 -0
  98. data/test/rails8_dummy/config/application.rb +1 -0
  99. data/test/rails8_dummy/config/coverband_rails_cache.rb +3 -0
  100. data/test/rails_test_helper.rb +2 -1
  101. data/test/test_helper.rb +5 -17
  102. data/views/file_list.erb +2 -2
  103. data/views/source_file.erb +4 -3
  104. metadata +48 -40
  105. data/lib/coverband/adapters/memcached_store.rb +0 -85
  106. data/lib/coverband/adapters/web_service_store.rb +0 -152
  107. data/lib/coverband/collectors/view_tracker_service.rb +0 -59
  108. data/test/coverband/adapters/memecached_store_test.rb +0 -26
  109. data/test/coverband/adapters/web_service_store_test.rb +0 -56
@@ -0,0 +1,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
@@ -28,10 +28,6 @@ module Coverband
28
28
  def save_report(report)
29
29
  $stdout.puts(report.to_json)
30
30
  end
31
-
32
- def raw_store
33
- raise NotImplementedError, "StdoutStore doesn't support raw_store"
34
- end
35
31
  end
36
32
  end
37
33
  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