datadog-ci 1.4.0 → 1.5.0

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -2
  3. data/README.md +1 -0
  4. data/ext/datadog_cov/datadog_cov.c +1 -1
  5. data/lib/datadog/ci/configuration/components.rb +12 -6
  6. data/lib/datadog/ci/configuration/settings.rb +6 -0
  7. data/lib/datadog/ci/contrib/cucumber/filter.rb +40 -0
  8. data/lib/datadog/ci/contrib/cucumber/instrumentation.rb +15 -1
  9. data/lib/datadog/ci/contrib/cucumber/patcher.rb +0 -2
  10. data/lib/datadog/ci/contrib/minitest/runner.rb +6 -1
  11. data/lib/datadog/ci/contrib/minitest/test.rb +4 -0
  12. data/lib/datadog/ci/contrib/rspec/example.rb +11 -10
  13. data/lib/datadog/ci/contrib/rspec/runner.rb +2 -1
  14. data/lib/datadog/ci/ext/settings.rb +1 -0
  15. data/lib/datadog/ci/ext/telemetry.rb +9 -0
  16. data/lib/datadog/ci/ext/test.rb +6 -1
  17. data/lib/datadog/ci/ext/transport.rb +7 -0
  18. data/lib/datadog/ci/remote/component.rb +13 -2
  19. data/lib/datadog/ci/remote/library_settings.rb +48 -7
  20. data/lib/datadog/ci/remote/library_settings_client.rb +2 -1
  21. data/lib/datadog/ci/remote/slow_test_retries.rb +53 -0
  22. data/lib/datadog/ci/test.rb +15 -2
  23. data/lib/datadog/ci/test_optimisation/component.rb +9 -6
  24. data/lib/datadog/ci/test_optimisation/skippable.rb +1 -1
  25. data/lib/datadog/ci/test_retries/component.rb +68 -39
  26. data/lib/datadog/ci/test_retries/driver/base.rb +25 -0
  27. data/lib/datadog/ci/test_retries/driver/no_retry.rb +16 -0
  28. data/lib/datadog/ci/test_retries/driver/retry_failed.rb +37 -0
  29. data/lib/datadog/ci/test_retries/driver/retry_new.rb +50 -0
  30. data/lib/datadog/ci/test_retries/null_component.rb +7 -6
  31. data/lib/datadog/ci/test_retries/strategy/base.rb +11 -4
  32. data/lib/datadog/ci/test_retries/strategy/no_retry.rb +0 -2
  33. data/lib/datadog/ci/test_retries/strategy/retry_failed.rb +30 -13
  34. data/lib/datadog/ci/test_retries/strategy/retry_new.rb +132 -0
  35. data/lib/datadog/ci/test_retries/unique_tests_client.rb +132 -0
  36. data/lib/datadog/ci/test_session.rb +2 -0
  37. data/lib/datadog/ci/test_suite.rb +8 -0
  38. data/lib/datadog/ci/test_visibility/component.rb +23 -13
  39. data/lib/datadog/ci/test_visibility/null_component.rb +1 -1
  40. data/lib/datadog/ci/test_visibility/telemetry.rb +9 -0
  41. data/lib/datadog/ci/utils/test_run.rb +1 -1
  42. data/lib/datadog/ci/version.rb +1 -1
  43. data/lib/datadog/ci.rb +6 -3
  44. metadata +11 -5
  45. data/lib/datadog/ci/contrib/cucumber/configuration_override.rb +0 -37
  46. data/lib/datadog/ci/utils/identity.rb +0 -20
@@ -99,6 +99,7 @@ module Datadog
99
99
 
100
100
  def start_coverage(test)
101
101
  return if !enabled? || !code_coverage?
102
+
102
103
  Telemetry.code_coverage_started(test)
103
104
  coverage_collector&.start
104
105
  end
@@ -109,13 +110,15 @@ module Datadog
109
110
  Telemetry.code_coverage_finished(test)
110
111
 
111
112
  coverage = coverage_collector&.stop
113
+
114
+ # if test was skipped, we discard coverage data
115
+ return if test.skipped?
116
+
112
117
  if coverage.nil? || coverage.empty?
113
118
  Telemetry.code_coverage_is_empty
114
119
  return
115
120
  end
116
121
 
117
- return if test.skipped?
118
-
119
122
  test_source_file = test.source_file
120
123
 
121
124
  # cucumber's gherkin files are not covered by the code coverage collector
@@ -140,8 +143,8 @@ module Datadog
140
143
  def mark_if_skippable(test)
141
144
  return if !enabled? || !skipping_tests?
142
145
 
143
- skippable_test_id = Utils::TestRun.skippable_test_id(test.name, test.test_suite_name, test.parameters)
144
- if @skippable_tests.include?(skippable_test_id)
146
+ datadog_test_id = Utils::TestRun.datadog_test_id(test.name, test.test_suite_name, test.parameters)
147
+ if @skippable_tests.include?(datadog_test_id)
145
148
  if forked?
146
149
  Datadog.logger.warn { "Intelligent test runner is not supported for forking test runners yet" }
147
150
  return
@@ -149,9 +152,9 @@ module Datadog
149
152
 
150
153
  test.set_tag(Ext::Test::TAG_ITR_SKIPPED_BY_ITR, "true")
151
154
 
152
- Datadog.logger.debug { "Marked test as skippable: #{skippable_test_id}" }
155
+ Datadog.logger.debug { "Marked test as skippable: #{datadog_test_id}" }
153
156
  else
154
- Datadog.logger.debug { "Test is not skippable: #{skippable_test_id}" }
157
+ Datadog.logger.debug { "Test is not skippable: #{datadog_test_id}" }
155
158
  end
156
159
  end
157
160
 
@@ -36,7 +36,7 @@ module Datadog
36
36
  next unless test_data["type"] == Ext::Test::ITR_TEST_SKIPPING_MODE
37
37
 
38
38
  attrs = test_data["attributes"] || {}
39
- res << Utils::TestRun.skippable_test_id(attrs["name"], attrs["suite"], attrs["parameters"])
39
+ res << Utils::TestRun.datadog_test_id(attrs["name"], attrs["suite"], attrs["parameters"])
40
40
  end
41
41
 
42
42
  res
@@ -1,7 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "driver/no_retry"
4
+ require_relative "driver/retry_failed"
5
+ require_relative "driver/retry_new"
6
+
3
7
  require_relative "strategy/no_retry"
4
8
  require_relative "strategy/retry_failed"
9
+ require_relative "strategy/retry_new"
10
+
11
+ require_relative "../ext/telemetry"
12
+ require_relative "../utils/telemetry"
5
13
 
6
14
  module Datadog
7
15
  module CI
@@ -10,73 +18,94 @@ module Datadog
10
18
  # - retrying failed tests - improve success rate of CI pipelines
11
19
  # - retrying new tests - detect flaky tests as early as possible to prevent them from being merged
12
20
  class Component
13
- attr_reader :retry_failed_tests_enabled, :retry_failed_tests_max_attempts,
14
- :retry_failed_tests_total_limit, :retry_failed_tests_count
21
+ FIBER_LOCAL_CURRENT_RETRY_DRIVER_KEY = :__dd_current_retry_driver
15
22
 
16
23
  def initialize(
17
24
  retry_failed_tests_enabled:,
18
25
  retry_failed_tests_max_attempts:,
19
- retry_failed_tests_total_limit:
26
+ retry_failed_tests_total_limit:,
27
+ retry_new_tests_enabled:,
28
+ unique_tests_client:
20
29
  )
21
- @retry_failed_tests_enabled = retry_failed_tests_enabled
22
- @retry_failed_tests_max_attempts = retry_failed_tests_max_attempts
23
- @retry_failed_tests_total_limit = retry_failed_tests_total_limit
24
- # counter that store the current number of failed tests retried
25
- @retry_failed_tests_count = 0
30
+ no_retries_strategy = Strategy::NoRetry.new
31
+
32
+ retry_failed_strategy = Strategy::RetryFailed.new(
33
+ enabled: retry_failed_tests_enabled,
34
+ max_attempts: retry_failed_tests_max_attempts,
35
+ total_limit: retry_failed_tests_total_limit
36
+ )
26
37
 
38
+ retry_new_strategy = Strategy::RetryNew.new(
39
+ enabled: retry_new_tests_enabled,
40
+ unique_tests_client: unique_tests_client
41
+ )
42
+
43
+ # order is important, we should try to retry new tests first
44
+ @retry_strategies = [retry_new_strategy, retry_failed_strategy, no_retries_strategy]
27
45
  @mutex = Mutex.new
28
46
  end
29
47
 
30
- def configure(library_settings)
31
- @retry_failed_tests_enabled &&= library_settings.flaky_test_retries_enabled?
48
+ def configure(library_settings, test_session)
49
+ # let all strategies configure themselves
50
+ @retry_strategies.each do |strategy|
51
+ strategy.configure(library_settings, test_session)
52
+ end
32
53
  end
33
54
 
34
55
  def with_retries(&block)
35
- # @type var retry_strategy: Strategy::Base
36
- retry_strategy = nil
37
-
38
- test_finished_callback = lambda do |test_span|
39
- if retry_strategy.nil?
40
- # we always run test at least once and after first pass create a correct retry strategy
41
- retry_strategy = build_strategy(test_span)
42
- else
43
- # after each retry we record the result, strategy will decide if we should retry again
44
- retry_strategy&.record_retry(test_span)
45
- end
46
- end
47
-
48
- test_visibility_component.set_test_finished_callback(test_finished_callback)
56
+ reset_retries!
49
57
 
50
58
  loop do
51
59
  yield
52
60
 
53
- break unless retry_strategy&.should_retry?
61
+ break unless should_retry?
54
62
  end
55
63
  ensure
56
- test_visibility_component.remove_test_finished_callback
64
+ reset_retries!
57
65
  end
58
66
 
59
- def build_strategy(test_span)
67
+ def build_driver(test_span)
60
68
  @mutex.synchronize do
61
- if should_retry_failed_test?(test_span)
62
- Datadog.logger.debug("Failed test retry starts")
63
- @retry_failed_tests_count += 1
64
-
65
- Strategy::RetryFailed.new(max_attempts: @retry_failed_tests_max_attempts)
66
- else
67
- Strategy::NoRetry.new
68
- end
69
+ # find the first strategy that covers the test span and let it build the driver
70
+ strategy = @retry_strategies.find { |strategy| strategy.covers?(test_span) }
71
+
72
+ raise "No retry strategy found for test span: #{test_span.name}" if strategy.nil?
73
+
74
+ strategy.build_driver(test_span)
69
75
  end
70
76
  end
71
77
 
78
+ def record_test_finished(test_span)
79
+ if current_retry_driver.nil?
80
+ # we always run test at least once and after the first pass create a correct retry driver
81
+ self.current_retry_driver = build_driver(test_span)
82
+ else
83
+ # after each retry we record the result, the driver will decide if we should retry again
84
+ current_retry_driver&.record_retry(test_span)
85
+ end
86
+ end
87
+
88
+ def record_test_span_duration(tracer_span)
89
+ current_retry_driver&.record_duration(tracer_span.duration)
90
+ end
91
+
92
+ # this API is targeted on Cucumber instrumentation or any other that cannot leverage #with_retries method
93
+ def reset_retries!
94
+ self.current_retry_driver = nil
95
+ end
96
+
97
+ def should_retry?
98
+ !!current_retry_driver&.should_retry?
99
+ end
100
+
72
101
  private
73
102
 
74
- def should_retry_failed_test?(test_span)
75
- @retry_failed_tests_enabled && !!test_span&.failed? && @retry_failed_tests_count < @retry_failed_tests_total_limit
103
+ def current_retry_driver
104
+ Thread.current[FIBER_LOCAL_CURRENT_RETRY_DRIVER_KEY]
76
105
  end
77
106
 
78
- def test_visibility_component
79
- Datadog.send(:components).test_visibility
107
+ def current_retry_driver=(driver)
108
+ Thread.current[FIBER_LOCAL_CURRENT_RETRY_DRIVER_KEY] = driver
80
109
  end
81
110
  end
82
111
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module CI
5
+ module TestRetries
6
+ module Driver
7
+ # Driver is the class responsible for the current test retry mechanism.
8
+ # It receives signals about each retry execution and steers the current retry strategy.
9
+ class Base
10
+ def should_retry?
11
+ false
12
+ end
13
+
14
+ def record_retry(test_span)
15
+ test_span&.set_tag(Ext::Test::TAG_IS_RETRY, "true")
16
+ end
17
+
18
+ # duration in float seconds
19
+ def record_duration(duration)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Datadog
6
+ module CI
7
+ module TestRetries
8
+ module Driver
9
+ class NoRetry < Base
10
+ def record_retry(test_span)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ require_relative "../../ext/test"
6
+
7
+ module Datadog
8
+ module CI
9
+ module TestRetries
10
+ module Driver
11
+ class RetryFailed < Base
12
+ attr_reader :max_attempts
13
+
14
+ def initialize(max_attempts:)
15
+ @max_attempts = max_attempts
16
+
17
+ @attempts = 0
18
+ @passed_once = false
19
+ end
20
+
21
+ def should_retry?
22
+ @attempts < @max_attempts && !@passed_once
23
+ end
24
+
25
+ def record_retry(test_span)
26
+ super
27
+
28
+ @attempts += 1
29
+ @passed_once = true if test_span&.passed?
30
+
31
+ Datadog.logger.debug { "Retry Attempts [#{@attempts} / #{@max_attempts}], Passed: [#{@passed_once}]" }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ require_relative "../../ext/test"
6
+
7
+ module Datadog
8
+ module CI
9
+ module TestRetries
10
+ module Driver
11
+ # retry every new test up to 10 times (early flake detection)
12
+ class RetryNew < Base
13
+ def initialize(test_span, max_attempts_thresholds:)
14
+ @max_attempts_thresholds = max_attempts_thresholds
15
+ @attempts = 0
16
+ # will be changed based on test span duration
17
+ @max_attempts = 10
18
+
19
+ mark_new_test(test_span)
20
+ end
21
+
22
+ def should_retry?
23
+ @attempts < @max_attempts
24
+ end
25
+
26
+ def record_retry(test_span)
27
+ super
28
+
29
+ @attempts += 1
30
+ mark_new_test(test_span)
31
+
32
+ Datadog.logger.debug { "Retry Attempts [#{@attempts} / #{@max_attempts}]" }
33
+ end
34
+
35
+ def record_duration(duration)
36
+ @max_attempts = @max_attempts_thresholds.max_attempts_for_duration(duration)
37
+
38
+ Datadog.logger.debug { "Recorded test duration of [#{duration}], new Max Attempts value is [#{@max_attempts}]" }
39
+ end
40
+
41
+ private
42
+
43
+ def mark_new_test(test_span)
44
+ test_span.set_tag(Ext::Test::TAG_IS_NEW, "true")
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -6,13 +6,7 @@ module Datadog
6
6
  module CI
7
7
  module TestRetries
8
8
  class NullComponent < Component
9
- attr_reader :retry_failed_tests_enabled, :retry_failed_tests_max_attempts, :retry_failed_tests_total_limit
10
-
11
9
  def initialize
12
- # enabled only by remote settings
13
- @retry_failed_tests_enabled = false
14
- @retry_failed_tests_max_attempts = 0
15
- @retry_failed_tests_total_limit = 0
16
10
  end
17
11
 
18
12
  def configure(library_settings)
@@ -22,6 +16,13 @@ module Datadog
22
16
  no_action = proc {}
23
17
  yield no_action
24
18
  end
19
+
20
+ def reset_retries!
21
+ end
22
+
23
+ def should_retry?
24
+ false
25
+ end
25
26
  end
26
27
  end
27
28
  end
@@ -1,16 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../driver/no_retry"
4
+
3
5
  module Datadog
4
6
  module CI
5
7
  module TestRetries
6
8
  module Strategy
9
+ # Strategies are subcomponents of the retry mechanism. They are responsible for
10
+ # determining which tests should be retried and how.
7
11
  class Base
8
- def should_retry?
9
- false
12
+ def covers?(test_span)
13
+ true
14
+ end
15
+
16
+ def configure(_library_settings, _test_session)
10
17
  end
11
18
 
12
- def record_retry(test_span)
13
- test_span&.set_tag(Ext::Test::TAG_IS_RETRY, "true")
19
+ def build_driver(_test_span)
20
+ Driver::NoRetry.new
14
21
  end
15
22
  end
16
23
  end
@@ -7,8 +7,6 @@ module Datadog
7
7
  module TestRetries
8
8
  module Strategy
9
9
  class NoRetry < Base
10
- def record_retry(test_span)
11
- end
12
10
  end
13
11
  end
14
12
  end
@@ -2,33 +2,50 @@
2
2
 
3
3
  require_relative "base"
4
4
 
5
- require_relative "../../ext/test"
5
+ require_relative "../driver/retry_failed"
6
6
 
7
7
  module Datadog
8
8
  module CI
9
9
  module TestRetries
10
10
  module Strategy
11
11
  class RetryFailed < Base
12
- attr_reader :max_attempts
13
-
14
- def initialize(max_attempts:)
12
+ attr_reader :enabled, :max_attempts,
13
+ :total_limit, :retried_count
14
+
15
+ def initialize(
16
+ enabled:,
17
+ max_attempts:,
18
+ total_limit:
19
+ )
20
+ @enabled = enabled
15
21
  @max_attempts = max_attempts
22
+ @total_limit = total_limit
23
+ @retried_count = 0
24
+ end
25
+
26
+ def covers?(test_span)
27
+ return false unless @enabled
28
+
29
+ if @retried_count >= @total_limit
30
+ Datadog.logger.debug do
31
+ "Retry failed tests limit reached: [#{@retried_count}] out of [#{@total_limit}]"
32
+ end
33
+ @enabled = false
34
+ end
16
35
 
17
- @attempts = 0
18
- @passed_once = false
36
+ @enabled && !!test_span&.failed?
19
37
  end
20
38
 
21
- def should_retry?
22
- @attempts < @max_attempts && !@passed_once
39
+ def configure(library_settings, test_session)
40
+ @enabled &&= library_settings.flaky_test_retries_enabled?
23
41
  end
24
42
 
25
- def record_retry(test_span)
26
- super
43
+ def build_driver(test_span)
44
+ Datadog.logger.debug { "#{test_span.name} failed, will be retried" }
27
45
 
28
- @attempts += 1
29
- @passed_once = true if test_span&.passed?
46
+ @retried_count += 1
30
47
 
31
- Datadog.logger.debug { "Retry Attempts [#{@attempts} / #{@max_attempts}], Passed: [#{@passed_once}]" }
48
+ Driver::RetryFailed.new(max_attempts: max_attempts)
32
49
  end
33
50
  end
34
51
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ require_relative "../driver/retry_new"
6
+
7
+ module Datadog
8
+ module CI
9
+ module TestRetries
10
+ module Strategy
11
+ class RetryNew < Base
12
+ DEFAULT_TOTAL_TESTS_COUNT = 100
13
+
14
+ attr_reader :enabled, :max_attempts_thresholds, :unique_tests_set, :total_limit, :retried_count
15
+
16
+ def initialize(
17
+ enabled:,
18
+ unique_tests_client:
19
+ )
20
+ @enabled = enabled
21
+ @unique_tests_set = Set.new
22
+ # total maximum number of new tests to retry (will be set based on the total number of tests in the session)
23
+ @total_limit = 0
24
+ @retried_count = 0
25
+
26
+ @unique_tests_client = unique_tests_client
27
+ end
28
+
29
+ def covers?(test_span)
30
+ return false unless @enabled
31
+
32
+ if @retried_count >= @total_limit
33
+ Datadog.logger.debug do
34
+ "Retry new tests limit reached: [#{@retried_count}] out of [#{@total_limit}]"
35
+ end
36
+ @enabled = false
37
+ mark_test_session_faulty(Datadog::CI.active_test_session)
38
+ end
39
+
40
+ @enabled && !test_span.skipped? && is_new_test?(test_span)
41
+ end
42
+
43
+ def configure(library_settings, test_session)
44
+ @enabled &&= library_settings.early_flake_detection_enabled?
45
+
46
+ return unless @enabled
47
+
48
+ # mark early flake detection enabled for test session
49
+ test_session.set_tag(Ext::Test::TAG_EARLY_FLAKE_ENABLED, "true")
50
+
51
+ set_max_attempts_thresholds(library_settings)
52
+ calculate_total_retries_limit(library_settings, test_session)
53
+ fetch_known_unique_tests(test_session)
54
+ end
55
+
56
+ def build_driver(test_span)
57
+ Datadog.logger.debug do
58
+ "#{test_span.name} is new, will be retried"
59
+ end
60
+ @retried_count += 1
61
+
62
+ Driver::RetryNew.new(test_span, max_attempts_thresholds: @max_attempts_thresholds)
63
+ end
64
+
65
+ private
66
+
67
+ def mark_test_session_faulty(test_session)
68
+ test_session&.set_tag(Ext::Test::TAG_EARLY_FLAKE_ABORT_REASON, Ext::Test::EARLY_FLAKE_FAULTY)
69
+ end
70
+
71
+ def is_new_test?(test_span)
72
+ test_id = Utils::TestRun.datadog_test_id(test_span.name, test_span.test_suite_name)
73
+
74
+ result = !@unique_tests_set.include?(test_id)
75
+
76
+ if result
77
+ Datadog.logger.debug do
78
+ "#{test_id} is not found in the unique tests set, it is a new test"
79
+ end
80
+ end
81
+
82
+ result
83
+ end
84
+
85
+ def set_max_attempts_thresholds(library_settings)
86
+ @max_attempts_thresholds = library_settings.slow_test_retries
87
+ Datadog.logger.debug do
88
+ "Slow test retries thresholds: #{@max_attempts_thresholds.entries}"
89
+ end
90
+ end
91
+
92
+ def calculate_total_retries_limit(library_settings, test_session)
93
+ percentage_limit = library_settings.faulty_session_threshold
94
+ tests_count = test_session.total_tests_count.to_i
95
+ if tests_count.zero?
96
+ Datadog.logger.debug do
97
+ "Total tests count is zero, using default value for the total number of tests: [#{DEFAULT_TOTAL_TESTS_COUNT}]"
98
+ end
99
+
100
+ tests_count = DEFAULT_TOTAL_TESTS_COUNT
101
+ end
102
+ @total_limit = (tests_count * percentage_limit / 100.0).ceil
103
+ Datadog.logger.debug do
104
+ "Retry new tests total limit is [#{@total_limit}] (#{percentage_limit}%) of #{tests_count}"
105
+ end
106
+ end
107
+
108
+ def fetch_known_unique_tests(test_session)
109
+ @unique_tests_set = @unique_tests_client.fetch_unique_tests(test_session)
110
+ if @unique_tests_set.empty?
111
+ @enabled = false
112
+ mark_test_session_faulty(test_session)
113
+
114
+ Datadog.logger.warn(
115
+ "Disabling early flake detection because there are no known tests (possible reason: no test runs in default branch)"
116
+ )
117
+ end
118
+
119
+ # report how many unique tests were found
120
+ Datadog.logger.debug do
121
+ "Found [#{@unique_tests_set.size}] known unique tests"
122
+ end
123
+ Utils::Telemetry.distribution(
124
+ Ext::Telemetry::METRIC_EFD_UNIQUE_TESTS_RESPONSE_TESTS,
125
+ @unique_tests_set.size.to_f
126
+ )
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end