flare 0.3.1 → 0.4.1

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.
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "concurrent/timer_task"
4
4
  require "concurrent/executor/fixed_thread_pool"
5
+ require "opentelemetry/sdk"
6
+
7
+ require_relative "deadline"
5
8
 
6
9
  module Flare
7
10
  # Background threads that periodically drain in-memory metrics and submit
@@ -10,6 +13,9 @@ module Flare
10
13
  #
11
14
  # Fork-safe: detects forked processes and restarts automatically.
12
15
  class MetricFlusher
16
+ SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
17
+ FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
18
+ TIMEOUT = OpenTelemetry::SDK::Trace::Export::TIMEOUT
13
19
  DEFAULT_INTERVAL = 60 # seconds
14
20
  DEFAULT_SHUTDOWN_TIMEOUT = 5 # seconds
15
21
 
@@ -23,6 +29,7 @@ module Flare
23
29
  @health_reporters = Array(health_reporters)
24
30
  @pid = $$
25
31
  @stopped = false
32
+ initialize_synchronization
26
33
  end
27
34
 
28
35
  def start
@@ -40,23 +47,25 @@ module Flare
40
47
  }) { post_to_pool }
41
48
  end
42
49
 
43
- def stop
50
+ def stop(timeout: @shutdown_timeout)
44
51
  return if @stopped
45
52
 
53
+ deadline = Deadline.new(timeout)
46
54
  @stopped = true
47
55
 
48
56
  log "Shutting down metrics flusher, draining remaining metrics..."
49
57
 
50
58
  if @timer
51
59
  @timer.shutdown
52
- @timer.wait_for_termination(1)
60
+ @timer.wait_for_termination([deadline.remaining || 1, 1].min)
53
61
  @timer.kill unless @timer.shutdown?
54
62
  end
55
63
 
64
+ force_flush(timeout: deadline.remaining)
65
+
56
66
  if @pool
57
- post_to_pool # one last drain
58
67
  @pool.shutdown
59
- pool_terminated = @pool.wait_for_termination(@shutdown_timeout)
68
+ pool_terminated = @pool.wait_for_termination(deadline.remaining || @shutdown_timeout)
60
69
  @pool.kill unless pool_terminated
61
70
  end
62
71
 
@@ -70,14 +79,11 @@ module Flare
70
79
  end
71
80
 
72
81
  # Manually trigger a flush (useful for testing or forced flushes).
73
- def flush_now
82
+ def flush_now(timeout: nil)
74
83
  return 0 unless @storage && @submitter
75
84
 
76
- record_health_metrics
77
- drained = @storage.drain
78
- return 0 if drained.empty?
79
-
80
- count, error = @submitter.submit(drained)
85
+ detect_forking
86
+ count, error, = flush_synchronously(Deadline.new(timeout))
81
87
  if error
82
88
  warn "[Flare] Metric submission error: #{error.message}"
83
89
  end
@@ -87,6 +93,21 @@ module Flare
87
93
  0
88
94
  end
89
95
 
96
+ def force_flush(timeout: nil)
97
+ return SUCCESS unless @storage && @submitter
98
+
99
+ detect_forking
100
+ deadline = Deadline.new(timeout)
101
+ _count, error, timed_out = flush_synchronously(deadline)
102
+ return TIMEOUT if timed_out || deadline.expired?
103
+ return FAILURE if error
104
+
105
+ SUCCESS
106
+ rescue => e
107
+ warn "[Flare] Metric flush error: #{e.message}"
108
+ FAILURE
109
+ end
110
+
90
111
  def running?
91
112
  @timer&.running? || false
92
113
  end
@@ -95,23 +116,49 @@ module Flare
95
116
  # on first span in the new process, or manually from Puma/Unicorn
96
117
  # after_fork hooks.
97
118
  def after_fork
119
+ return if @pid == $$
120
+
98
121
  @pid = $$
99
- restart
122
+ @storage.after_fork if @storage.respond_to?(:after_fork)
123
+ initialize_synchronization
124
+ @timer = nil
125
+ @pool = nil
126
+ start
100
127
  end
101
128
 
102
129
  private
103
130
 
131
+ def detect_forking
132
+ after_fork if @pid != $$
133
+ end
134
+
135
+ def initialize_synchronization
136
+ @submission_mutex = Mutex.new
137
+ @submission_condition = ConditionVariable.new
138
+ @pending_submissions = 0
139
+ @flush_owner = nil
140
+ end
141
+
104
142
  def post_to_pool
143
+ return unless reserve_background_submission
144
+
105
145
  record_health_metrics
106
146
  drained = @storage.drain
107
147
  if drained.empty?
108
148
  log "No metrics to flush"
149
+ background_submission_finished
109
150
  return
110
151
  end
111
152
 
112
153
  log "Drained #{drained.size} metric keys for submission"
113
- @pool.post { submit_to_cloud(drained) }
154
+ posted = @pool.post do
155
+ submit_to_cloud(drained)
156
+ ensure
157
+ background_submission_finished
158
+ end
159
+ background_submission_finished unless posted
114
160
  rescue => e
161
+ background_submission_finished
115
162
  warn "[Flare] Metric drain error: #{e.message}"
116
163
  end
117
164
 
@@ -124,6 +171,103 @@ module Flare
124
171
  warn "[Flare] Metric submission error: #{e.message}"
125
172
  end
126
173
 
174
+ def reserve_background_submission
175
+ @submission_mutex.synchronize do
176
+ return false if @flush_owner || @pending_submissions.positive?
177
+
178
+ @pending_submissions += 1
179
+ true
180
+ end
181
+ end
182
+
183
+ def background_submission_finished
184
+ @submission_mutex.synchronize do
185
+ @pending_submissions -= 1 if @pending_submissions.positive?
186
+ @submission_condition.broadcast
187
+ end
188
+ end
189
+
190
+ def flush_synchronously(deadline)
191
+ return [0, nil, true] unless begin_synchronous_flush(deadline)
192
+
193
+ record_health_metrics
194
+ drained = @storage.drain
195
+ return [0, nil, false] if drained.empty?
196
+
197
+ submit_with_deadline(drained, deadline)
198
+ ensure
199
+ finish_synchronous_flush if @flush_owner == Thread.current
200
+ end
201
+
202
+ def begin_synchronous_flush(deadline)
203
+ @submission_mutex.synchronize do
204
+ while @flush_owner && @flush_owner != Thread.current
205
+ return false if deadline.expired?
206
+
207
+ @submission_condition.wait(@submission_mutex, deadline.remaining)
208
+ end
209
+ @flush_owner = Thread.current
210
+
211
+ while @pending_submissions.positive?
212
+ return false if deadline.expired?
213
+
214
+ @submission_condition.wait(@submission_mutex, deadline.remaining)
215
+ end
216
+ end
217
+ true
218
+ end
219
+
220
+ def finish_synchronous_flush
221
+ @submission_mutex.synchronize do
222
+ @flush_owner = nil
223
+ @submission_condition.broadcast
224
+ end
225
+ end
226
+
227
+ def submit_metrics(drained, timeout:)
228
+ parameters = @submitter.method(:submit).parameters
229
+ accepts_timeout = parameters.any? do |type, name|
230
+ type == :keyrest || ([:key, :keyreq].include?(type) && name == :timeout)
231
+ end
232
+
233
+ if accepts_timeout
234
+ @submitter.submit(drained, timeout: timeout)
235
+ else
236
+ @submitter.submit(drained)
237
+ end
238
+ end
239
+
240
+ def submit_with_deadline(drained, deadline)
241
+ operation = { done: false, count: 0, error: nil }
242
+ @submission_mutex.synchronize { @pending_submissions += 1 }
243
+ Thread.new do
244
+ operation[:count], operation[:error] = submit_metrics(drained, timeout: deadline.remaining)
245
+ rescue => e
246
+ operation[:error] = e
247
+ ensure
248
+ @submission_mutex.synchronize do
249
+ operation[:done] = true
250
+ @pending_submissions -= 1
251
+ @submission_condition.broadcast
252
+ end
253
+ end
254
+
255
+ @submission_mutex.synchronize do
256
+ until operation[:done]
257
+ return [0, nil, true] if deadline.expired?
258
+
259
+ @submission_condition.wait(@submission_mutex, deadline.remaining)
260
+ end
261
+ end
262
+
263
+ timed_out = deadline.expired? || deadline_error?(operation[:error])
264
+ [operation[:count], operation[:error], timed_out]
265
+ end
266
+
267
+ def deadline_error?(error)
268
+ defined?(MetricSubmitter::DeadlineExceeded) && error.is_a?(MetricSubmitter::DeadlineExceeded)
269
+ end
270
+
127
271
  def record_health_metrics
128
272
  @health_reporters.each { |reporter| reporter.record(@storage) }
129
273
  rescue => e
@@ -9,14 +9,17 @@ module Flare
9
9
  class MetricStorage
10
10
  def initialize
11
11
  @storage = Concurrent::Map.new
12
+ @pid = $$
12
13
  end
13
14
 
14
15
  def increment(key, duration_ms:, error: false)
16
+ detect_forking
15
17
  counter = @storage.compute_if_absent(key) { MetricCounter.new }
16
18
  counter.increment(duration_ms: duration_ms, error: error)
17
19
  end
18
20
 
19
21
  def add(key, count:, sum_ms:, error_count: 0)
22
+ detect_forking
20
23
  counter = @storage.compute_if_absent(key) { MetricCounter.new }
21
24
  counter.add(count: count, sum_ms: sum_ms, error_count: error_count)
22
25
  end
@@ -24,6 +27,7 @@ module Flare
24
27
  # Atomically retrieves and clears all metrics.
25
28
  # Returns a frozen hash of MetricKey => counter data.
26
29
  def drain
30
+ detect_forking
27
31
  result = {}
28
32
  @storage.keys.each do |key|
29
33
  counter = @storage.delete(key)
@@ -33,15 +37,31 @@ module Flare
33
37
  end
34
38
 
35
39
  def size
40
+ detect_forking
36
41
  @storage.size
37
42
  end
38
43
 
39
44
  def empty?
45
+ detect_forking
40
46
  @storage.empty?
41
47
  end
42
48
 
43
49
  def [](key)
50
+ detect_forking
44
51
  @storage[key]
45
52
  end
53
+
54
+ def after_fork
55
+ return if @pid == $$
56
+
57
+ @pid = $$
58
+ @storage = Concurrent::Map.new
59
+ end
60
+
61
+ private
62
+
63
+ def detect_forking
64
+ after_fork
65
+ end
46
66
  end
47
67
  end
@@ -7,6 +7,7 @@ require "stringio"
7
7
  require "securerandom"
8
8
 
9
9
  require_relative "client_headers"
10
+ require_relative "deadline"
10
11
 
11
12
  module Flare
12
13
  # Submits metrics to the Flare metrics service via HTTP.
@@ -45,6 +46,8 @@ module Flare
45
46
  end
46
47
  end
47
48
 
49
+ class DeadlineExceeded < StandardError; end
50
+
48
51
  attr_reader :endpoint, :api_key, :backoff_policy
49
52
 
50
53
  def initialize(endpoint:, api_key:, project: nil, environment: nil, backoff_policy: nil, open_timeout: nil, read_timeout: nil, write_timeout: nil)
@@ -60,23 +63,28 @@ module Flare
60
63
 
61
64
  # Submit drained metrics to the server.
62
65
  # Returns [success_count, error] where error may be nil on success.
63
- def submit(drained)
66
+ def submit(drained, timeout: nil)
64
67
  return [0, nil] if drained.empty?
65
68
 
69
+ deadline = Deadline.new(timeout)
70
+
66
71
  request_id = SecureRandom.uuid
67
- Flare.log "Submitting #{drained.size} metrics to #{@endpoint} (request_id=#{request_id})"
72
+ Flare.log "Submitting #{drained.size} metrics to #{@endpoint} (request_id=#{request_id})" if Flare.respond_to?(:log)
68
73
 
69
74
  body = build_body(drained, request_id)
70
75
  return [0, nil] if body.nil?
76
+ return [0, DeadlineExceeded.new("metric submission deadline exceeded")] if deadline.expired?
71
77
 
72
78
  @backoff_policy.reset
73
- response, error = retry_with_backoff(MAX_RETRIES) { post(body, request_id) }
79
+ response, error = retry_with_backoff(MAX_RETRIES, deadline: deadline) do
80
+ post(body, request_id, timeout: deadline.remaining)
81
+ end
74
82
 
75
83
  if error
76
- Flare.log "Submission failed: #{error.message} (request_id=#{request_id})"
84
+ Flare.log "Submission failed: #{error.message} (request_id=#{request_id})" if Flare.respond_to?(:log)
77
85
  [0, error]
78
86
  else
79
- Flare.log "Submission succeeded: #{response.code} (request_id=#{request_id})"
87
+ Flare.log "Submission succeeded: #{response.code} (request_id=#{request_id})" if Flare.respond_to?(:log)
80
88
  [drained.size, nil]
81
89
  end
82
90
  end
@@ -111,12 +119,14 @@ module Flare
111
119
  nil
112
120
  end
113
121
 
114
- def post(body, request_id)
122
+ def post(body, request_id, timeout: nil)
123
+ raise DeadlineExceeded, "metric submission deadline exceeded" if timeout == 0
124
+
115
125
  http = Net::HTTP.new(@endpoint.host, @endpoint.port)
116
126
  http.use_ssl = @endpoint.scheme == "https"
117
- http.open_timeout = @open_timeout
118
- http.read_timeout = @read_timeout
119
- http.write_timeout = @write_timeout if http.respond_to?(:write_timeout=)
127
+ http.open_timeout = effective_timeout(@open_timeout, timeout)
128
+ http.read_timeout = effective_timeout(@read_timeout, timeout)
129
+ http.write_timeout = effective_timeout(@write_timeout, timeout) if http.respond_to?(:write_timeout=)
120
130
 
121
131
  request_uri = @endpoint.request_uri
122
132
  request = Net::HTTP::Post.new(request_uri == "" ? "/" : request_uri)
@@ -158,20 +168,27 @@ module Flare
158
168
  )
159
169
  end
160
170
 
161
- def retry_with_backoff(max_attempts)
171
+ def retry_with_backoff(max_attempts, deadline:)
162
172
  attempts_remaining = max_attempts
163
173
  last_error = nil
164
174
 
165
175
  while attempts_remaining > 0
166
176
  begin
177
+ return [nil, DeadlineExceeded.new("metric submission deadline exceeded")] if deadline.expired?
178
+
167
179
  result, should_retry = yield
180
+ return [nil, DeadlineExceeded.new("metric submission deadline exceeded")] if deadline.expired?
168
181
  return [result, nil] unless should_retry
169
- rescue SubmissionError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET => e
182
+ rescue SubmissionError, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET => e
170
183
  last_error = e
171
184
  attempts_remaining -= 1
172
185
 
173
186
  if attempts_remaining > 0
174
187
  sleep_time = @backoff_policy.next_interval / 1000.0
188
+ remaining = deadline.remaining
189
+ if remaining && sleep_time >= remaining
190
+ return [nil, DeadlineExceeded.new("metric submission deadline exceeded")]
191
+ end
175
192
  sleep(sleep_time)
176
193
  end
177
194
  next
@@ -184,6 +201,12 @@ module Flare
184
201
  [nil, last_error]
185
202
  end
186
203
 
204
+ def effective_timeout(configured_timeout, remaining)
205
+ return configured_timeout unless remaining
206
+
207
+ [configured_timeout, remaining].min
208
+ end
209
+
187
210
  def gzip(string)
188
211
  io = StringIO.new
189
212
  io.set_encoding("BINARY")
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "opentelemetry/sdk"
5
+
6
+ require_relative "deadline"
7
+
8
+ module Flare
9
+ # An asynchronous, bounded span processor that exports every ended recording
10
+ # span. OpenTelemetry's BatchSpanProcessor only accepts sampled spans, which
11
+ # excludes RECORD_ONLY spans needed by Flare's local development dashboard.
12
+ class RecordingBatchSpanProcessor
13
+ SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
14
+ FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
15
+ TIMEOUT = OpenTelemetry::SDK::Trace::Export::TIMEOUT
16
+
17
+ def initialize(exporter, exporter_timeout: 30_000, schedule_delay: 5_000,
18
+ max_queue_size: 2_048, max_export_batch_size: 512, logger: nil)
19
+ raise ArgumentError if max_export_batch_size > max_queue_size
20
+
21
+ @exporter = exporter
22
+ @exporter_timeout = exporter_timeout / 1_000.0
23
+ @schedule_delay = schedule_delay / 1_000.0
24
+ @max_queue_size = max_queue_size
25
+ @max_export_batch_size = max_export_batch_size
26
+ @logger = logger || Logger.new($stderr, level: Logger::WARN)
27
+ @pid = $$
28
+ initialize_synchronization
29
+ start_worker
30
+ end
31
+
32
+ def on_start(_span, _parent_context); end
33
+
34
+ def on_finish(span)
35
+ detect_forking
36
+
37
+ @mutex.synchronize do
38
+ overflow = @queue.length + 1 - @max_queue_size
39
+ @queue.shift(overflow) if overflow.positive?
40
+ @queue << span
41
+ @condition.signal if @queue.length >= @max_export_batch_size
42
+ end
43
+ end
44
+
45
+ def force_flush(timeout: nil)
46
+ detect_forking
47
+ deadline = Deadline.new(timeout)
48
+ return TIMEOUT unless begin_flush(deadline)
49
+
50
+ snapshot = snapshot_for_flush
51
+ operation = start_flush_export(snapshot, deadline)
52
+ result = wait_for_flush_export(operation, deadline)
53
+ [@flush_prior_result, result].max
54
+ rescue StandardError => e
55
+ log_export_error(e)
56
+ FAILURE
57
+ ensure
58
+ finish_flush if @flush_owner == Thread.current
59
+ end
60
+
61
+ def shutdown(timeout: nil)
62
+ detect_forking
63
+ deadline = Deadline.new(timeout)
64
+
65
+ worker = @mutex.synchronize do
66
+ @stopped = true
67
+ @condition.broadcast
68
+ @worker
69
+ end
70
+ worker&.join(deadline.remaining)
71
+ return TIMEOUT if worker&.alive? || deadline.expired?
72
+
73
+ result = force_flush(timeout: deadline.remaining)
74
+ return result unless result == SUCCESS
75
+ return TIMEOUT if deadline.expired?
76
+
77
+ exporter_result = @exporter.shutdown(timeout: deadline.remaining)
78
+ deadline.expired? ? TIMEOUT : exporter_result
79
+ rescue StandardError => e
80
+ log_export_error(e)
81
+ FAILURE
82
+ end
83
+
84
+ private
85
+
86
+ def initialize_synchronization
87
+ @mutex = Mutex.new
88
+ @condition = ConditionVariable.new
89
+ @queue = []
90
+ @active_exports = 0
91
+ @export_completion_sequence = 0
92
+ @last_export_result = SUCCESS
93
+ @flush_prior_result = SUCCESS
94
+ @flush_owner = nil
95
+ @stopped = false
96
+ @worker = nil
97
+ end
98
+
99
+ def worker_loop
100
+ loop do
101
+ batch = @mutex.synchronize do
102
+ while !@stopped && (@queue.empty? || @flush_owner || @active_exports.positive?)
103
+ @condition.wait(@mutex, @schedule_delay)
104
+ break if !@queue.empty? && !@flush_owner && @active_exports.zero?
105
+ end
106
+ return if @stopped
107
+
108
+ @active_exports += 1
109
+ @queue.shift(@max_export_batch_size)
110
+ end
111
+
112
+ result = export_batch(batch, timeout: @exporter_timeout)
113
+ ensure
114
+ export_finished(result || FAILURE) if batch
115
+ end
116
+ end
117
+
118
+ def begin_flush(deadline)
119
+ @mutex.synchronize do
120
+ initial_sequence = @export_completion_sequence
121
+ while @flush_owner && @flush_owner != Thread.current
122
+ return false if deadline.expired?
123
+
124
+ @condition.wait(@mutex, deadline.remaining)
125
+ end
126
+ @flush_owner = Thread.current
127
+
128
+ while @active_exports.positive?
129
+ return false if deadline.expired?
130
+
131
+ @condition.wait(@mutex, deadline.remaining)
132
+ end
133
+ @flush_prior_result = if @export_completion_sequence > initial_sequence
134
+ @last_export_result
135
+ else
136
+ SUCCESS
137
+ end
138
+ end
139
+ true
140
+ end
141
+
142
+ def finish_flush
143
+ @mutex.synchronize do
144
+ @flush_owner = nil
145
+ @condition.broadcast
146
+ end
147
+ end
148
+
149
+ def snapshot_for_flush
150
+ @mutex.synchronize { @queue.shift(@queue.length) }
151
+ end
152
+
153
+ def export_snapshot(snapshot, deadline)
154
+ until snapshot.empty?
155
+ return TIMEOUT if deadline.expired?
156
+
157
+ batch = snapshot.shift(@max_export_batch_size)
158
+ result = export_batch(batch, timeout: deadline.remaining)
159
+ return result unless result == SUCCESS
160
+ end
161
+ SUCCESS
162
+ ensure
163
+ @mutex.synchronize { @queue.unshift(*snapshot) } if snapshot&.any?
164
+ end
165
+
166
+ def start_flush_export(snapshot, deadline)
167
+ operation = { done: false, result: nil }
168
+ @mutex.synchronize { @active_exports += 1 }
169
+ Thread.new do
170
+ result = export_snapshot(snapshot, deadline)
171
+ if result == SUCCESS && !deadline.expired?
172
+ result = @exporter.force_flush(timeout: deadline.remaining)
173
+ end
174
+ operation[:result] = deadline.expired? ? TIMEOUT : result
175
+ rescue StandardError => e
176
+ log_export_error(e)
177
+ operation[:result] = FAILURE
178
+ ensure
179
+ @mutex.synchronize do
180
+ operation[:done] = true
181
+ complete_export(operation[:result])
182
+ end
183
+ end
184
+ operation
185
+ end
186
+
187
+ def wait_for_flush_export(operation, deadline)
188
+ @mutex.synchronize do
189
+ until operation[:done]
190
+ return TIMEOUT if deadline.expired?
191
+
192
+ @condition.wait(@mutex, deadline.remaining)
193
+ end
194
+ end
195
+ operation[:result]
196
+ end
197
+
198
+ def export_batch(spans, timeout:)
199
+ span_data = spans.map { |span| span.respond_to?(:to_span_data) ? span.to_span_data : span }
200
+ @exporter.export(span_data, timeout: timeout)
201
+ rescue StandardError => e
202
+ log_export_error(e)
203
+ FAILURE
204
+ end
205
+
206
+ def export_finished(result)
207
+ @mutex.synchronize do
208
+ complete_export(result)
209
+ end
210
+ end
211
+
212
+ def complete_export(result)
213
+ @active_exports -= 1
214
+ @export_completion_sequence += 1
215
+ @last_export_result = result || FAILURE
216
+ @condition.broadcast
217
+ end
218
+
219
+ def detect_forking
220
+ return if @pid == $$
221
+
222
+ # Only the forking thread survives. Replacing synchronization objects
223
+ # avoids waiting on locks or in-flight state owned by vanished threads.
224
+ @pid = $$
225
+ initialize_synchronization
226
+ start_worker
227
+ end
228
+
229
+ def start_worker
230
+ @worker = Thread.new { worker_loop }
231
+ @worker.name = "flare-recording-batch-span-processor"
232
+ end
233
+
234
+ def log_export_error(error)
235
+ @logger.warn("[Flare::RecordingBatchSpanProcessor] export failed: #{error.class}: #{error.message}")
236
+ end
237
+ end
238
+ end
@@ -60,10 +60,10 @@ module Flare
60
60
  self
61
61
  end
62
62
 
63
- def stop
63
+ def stop(timeout: 1)
64
64
  if @timer
65
65
  @timer.shutdown
66
- @timer.wait_for_termination(1)
66
+ @timer.wait_for_termination(timeout)
67
67
  @timer.kill unless @timer.shutdown?
68
68
  @timer = nil
69
69
  end