jiggler 0.1.0.rc2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE +4 -0
  4. data/README.md +423 -0
  5. data/bin/jiggler +31 -0
  6. data/lib/jiggler/cleaner.rb +130 -0
  7. data/lib/jiggler/cli.rb +263 -0
  8. data/lib/jiggler/config.rb +165 -0
  9. data/lib/jiggler/core.rb +22 -0
  10. data/lib/jiggler/errors.rb +5 -0
  11. data/lib/jiggler/job.rb +116 -0
  12. data/lib/jiggler/launcher.rb +69 -0
  13. data/lib/jiggler/manager.rb +73 -0
  14. data/lib/jiggler/redis_store.rb +55 -0
  15. data/lib/jiggler/retrier.rb +122 -0
  16. data/lib/jiggler/scheduled/enqueuer.rb +78 -0
  17. data/lib/jiggler/scheduled/poller.rb +97 -0
  18. data/lib/jiggler/stats/collection.rb +26 -0
  19. data/lib/jiggler/stats/monitor.rb +103 -0
  20. data/lib/jiggler/summary.rb +101 -0
  21. data/lib/jiggler/support/helper.rb +35 -0
  22. data/lib/jiggler/version.rb +5 -0
  23. data/lib/jiggler/web/assets/stylesheets/application.css +64 -0
  24. data/lib/jiggler/web/views/application.erb +329 -0
  25. data/lib/jiggler/web.rb +80 -0
  26. data/lib/jiggler/worker.rb +179 -0
  27. data/lib/jiggler.rb +10 -0
  28. data/spec/examples.txt +79 -0
  29. data/spec/fixtures/config/jiggler.yml +4 -0
  30. data/spec/fixtures/jobs.rb +5 -0
  31. data/spec/fixtures/my_failed_job.rb +10 -0
  32. data/spec/fixtures/my_job.rb +9 -0
  33. data/spec/fixtures/my_job_with_args.rb +18 -0
  34. data/spec/jiggler/cleaner_spec.rb +171 -0
  35. data/spec/jiggler/cli_spec.rb +87 -0
  36. data/spec/jiggler/config_spec.rb +56 -0
  37. data/spec/jiggler/core_spec.rb +34 -0
  38. data/spec/jiggler/job_spec.rb +99 -0
  39. data/spec/jiggler/launcher_spec.rb +66 -0
  40. data/spec/jiggler/manager_spec.rb +52 -0
  41. data/spec/jiggler/redis_store_spec.rb +20 -0
  42. data/spec/jiggler/retrier_spec.rb +55 -0
  43. data/spec/jiggler/scheduled/enqueuer_spec.rb +81 -0
  44. data/spec/jiggler/scheduled/poller_spec.rb +40 -0
  45. data/spec/jiggler/stats/monitor_spec.rb +40 -0
  46. data/spec/jiggler/summary_spec.rb +168 -0
  47. data/spec/jiggler/web_spec.rb +37 -0
  48. data/spec/jiggler/worker_spec.rb +110 -0
  49. data/spec/spec_helper.rb +54 -0
  50. metadata +230 -0
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'optparse'
5
+ require 'yaml'
6
+ require 'async'
7
+ require 'async/io/trap'
8
+ require 'async/pool'
9
+
10
+ module Jiggler
11
+ class CLI
12
+ include Singleton
13
+ CONTEXT_SWITCHER_THRESHOLD = 0.5
14
+
15
+ attr_reader :logger, :config, :environment
16
+
17
+ SIGNAL_HANDLERS = {
18
+ :INT => ->(cli) {
19
+ cli.logger.fatal('Received INT, shutting down')
20
+ cli.stop
21
+ },
22
+ :TERM => ->(cli) {
23
+ cli.logger.fatal('Received TERM, shutting down')
24
+ cli.stop
25
+ },
26
+ :TSTP => ->(cli) {
27
+ cli.logger.info('Received TSTP, no longer accepting new work')
28
+ cli.suspend
29
+ }
30
+ }
31
+ UNHANDLED_SIGNAL_HANDLER = ->(cli) { cli.logger.info('No signal handler registered, ignoring') }
32
+ SIGNAL_HANDLERS.default = UNHANDLED_SIGNAL_HANDLER
33
+ SIGNAL_HANDLERS.freeze
34
+
35
+ def parse_and_init(args = ARGV.dup)
36
+ @config ||= Jiggler.config
37
+
38
+ setup_options(args)
39
+ initialize_logger
40
+ validate!
41
+ load_app
42
+ end
43
+
44
+ def start
45
+ return unless ping_redis
46
+ @cond = Async::Condition.new
47
+ Async do
48
+ setup_signal_handlers
49
+ patch_scheduler
50
+ @launcher = Launcher.new(config)
51
+ @launcher.start
52
+ Async do
53
+ @cond.wait
54
+ end
55
+ end
56
+ @switcher&.exit
57
+ end
58
+
59
+ def stop
60
+ @launcher.stop
61
+ logger.info('Jiggler is stopped, bye!')
62
+ @cond.signal
63
+ end
64
+
65
+ def suspend
66
+ @launcher.suspend
67
+ logger.info('Jiggler is suspended')
68
+ end
69
+
70
+ private
71
+
72
+ # forces scheduler to switch fibers if they take more than threshold to execute
73
+ def patch_scheduler
74
+ @switcher = Thread.new(Fiber.scheduler) do |scheduler|
75
+ loop do
76
+ sleep(CONTEXT_SWITCHER_THRESHOLD)
77
+ switch = scheduler.context_switch
78
+ next if switch.nil?
79
+ next if Process.clock_gettime(Process::CLOCK_MONOTONIC) - switch < CONTEXT_SWITCHER_THRESHOLD
80
+
81
+ Process.kill('URG', Process.pid)
82
+ end
83
+ end
84
+
85
+ Signal.trap('URG') do
86
+ next Fiber.scheduler.context_switch!(nil) unless Async::Task.current?
87
+ Async::Task.current.yield
88
+ end
89
+
90
+ Fiber.scheduler.instance_eval do
91
+ def context_switch
92
+ @context_switch
93
+ end
94
+
95
+ def context_switch!(value = Process.clock_gettime(Process::CLOCK_MONOTONIC))
96
+ @context_switch = value
97
+ end
98
+
99
+ def block(...)
100
+ context_switch!(nil)
101
+ super
102
+ end
103
+
104
+ def kernel_sleep(...)
105
+ context_switch!(nil)
106
+ super
107
+ end
108
+
109
+ def resume(fiber, *args)
110
+ context_switch!
111
+ super
112
+ end
113
+ end
114
+ end
115
+
116
+ def setup_signal_handlers
117
+ SIGNAL_HANDLERS.each do |signal, handler|
118
+ trap = Async::IO::Trap.new(signal)
119
+ trap.install!
120
+ Async(transient: true) do
121
+ trap.wait
122
+ invoked_traps[signal] += 1
123
+ handler.call(self)
124
+ end
125
+ end
126
+ end
127
+
128
+ def invoked_traps
129
+ @invoked_traps ||= Hash.new { |h, k| h[k] = 0 }
130
+ end
131
+
132
+ def validate!
133
+ if config[:queues].any? { |q| q.include?(':') }
134
+ raise ArgumentError, 'Queue names cannot contain colons'
135
+ end
136
+
137
+ [:concurrency, :client_concurrency, :timeout].each do |opt|
138
+ raise ArgumentError, "#{opt}: #{config[opt]} is not a valid value" if config[opt].to_i <= 0
139
+ end
140
+ end
141
+
142
+ def parse_options(argv)
143
+ opts = {}
144
+ @parser = option_parser(opts)
145
+ @parser.parse!(argv)
146
+ opts
147
+ end
148
+
149
+ def option_parser(opts)
150
+ parser = OptionParser.new do |o|
151
+ o.on '-c', '--concurrency INT', 'Number of fibers to use on the server' do |arg|
152
+ opts[:concurrency] = Integer(arg)
153
+ end
154
+
155
+ o.on '-e', '--environment ENV', 'Application environment' do |arg|
156
+ opts[:environment] = arg
157
+ end
158
+
159
+ o.on '-q', '--queue QUEUE1,QUEUE2', 'Queues to process' do |arg|
160
+ opts[:queues] ||= []
161
+ arg.split(',').each do |queue|
162
+ opts[:queues] << queue
163
+ end
164
+ end
165
+
166
+ o.on '-r', '--require PATH', 'File to require' do |arg|
167
+ opts[:require] = arg
168
+ end
169
+
170
+ o.on '-t', '--timeout NUM', 'Shutdown timeout' do |arg|
171
+ opts[:timeout] = Integer(arg)
172
+ end
173
+
174
+ o.on '-v', '--verbose', 'Print more verbose output' do |arg|
175
+ opts[:verbose] = arg
176
+ end
177
+
178
+ o.on '-C', '--config PATH', 'Path to YAML config file' do |arg|
179
+ opts[:config_file] = arg
180
+ end
181
+
182
+ o.on '-V', '--version', 'Print version and exit' do
183
+ puts("Jiggler #{Jiggler::VERSION}")
184
+ exit(0)
185
+ end
186
+
187
+ o.on_tail '-h', '--help', 'Show help' do
188
+ puts o
189
+ exit(0)
190
+ end
191
+ end
192
+ parser.banner = 'Jiggler [options]'
193
+ parser
194
+ end
195
+
196
+ def setup_options(args)
197
+ opts = parse_options(args)
198
+
199
+ set_environment(opts)
200
+
201
+ opts = parse_config(opts[:config_file]).merge(opts) if opts[:config_file]
202
+ opts[:queues] = [Jiggler::Config::DEFAULT_QUEUE] if opts[:queues].nil?
203
+ opts[:server_mode] = true # cli starts only in server mode
204
+ config.merge!(opts)
205
+ end
206
+
207
+ def set_environment(opts)
208
+ opts[:environment] ||= ENV['APP_ENV'] || 'development'
209
+ @environment = opts[:environment]
210
+ end
211
+
212
+ def initialize_logger
213
+ @logger = config.logger
214
+ logger.level = ::Logger::DEBUG if config[:verbose]
215
+ end
216
+
217
+ def symbolize_keys_deep!(hash)
218
+ hash.keys.each do |k|
219
+ symkey = k.respond_to?(:to_sym) ? k.to_sym : k
220
+ hash[symkey] = hash.delete k
221
+ symbolize_keys_deep! hash[symkey] if hash[symkey].is_a? Hash
222
+ end
223
+ end
224
+
225
+ def parse_config(path)
226
+ erb = ERB.new(File.read(path))
227
+ erb.filename = File.expand_path(path)
228
+ opts = YAML.safe_load(erb.result, permitted_classes: [Symbol], aliases: true) || {}
229
+
230
+ symbolize_keys_deep!(opts)
231
+
232
+ opts = opts.merge(opts.delete(environment.to_sym) || {})
233
+ opts.delete(:strict)
234
+
235
+ opts
236
+ rescue => error
237
+ raise ArgumentError, "Error parsing config file: #{error.message}"
238
+ end
239
+
240
+ def ping_redis
241
+ config.with_sync_redis { |conn| conn.call('PING') }
242
+ true
243
+ rescue => err
244
+ logger.fatal("Redis connection error: #{err.message}")
245
+ false
246
+ end
247
+
248
+ def load_app
249
+ if config[:require].nil? || config[:require].empty?
250
+ logger.warn('No require option specified. Please specify a Ruby file to require with --require')
251
+ # allow to start empty server
252
+ return
253
+ end
254
+ # the code required by this file is expected to call Jiggler.configure
255
+ # thus it'll be executed in the context of the current process
256
+ # and apply the configuration for the server
257
+ require config[:require]
258
+ rescue LoadError => e
259
+ logger.fatal("Could not load jobs: #{e.message}")
260
+ exit(1)
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'logger'
5
+
6
+ module Jiggler
7
+ class Config
8
+ extend Forwardable
9
+
10
+ DEFAULT_QUEUE = 'default'
11
+ QUEUE_PREFIX = 'jiggler:list:'
12
+ SERVER_PREFIX = 'jiggler:svr:'
13
+ RETRIES_SET = 'jiggler:set:retries'
14
+ SCHEDULED_SET = 'jiggler:set:scheduled'
15
+ DEAD_SET = 'jiggler:set:dead'
16
+ PROCESSED_COUNTER = 'jiggler:stats:processed_counter'
17
+ FAILURES_COUNTER = 'jiggler:stats:failures_counter'
18
+
19
+ DEFAULTS = {
20
+ require: nil,
21
+ environment: 'development',
22
+ concurrency: 10,
23
+ timeout: 25,
24
+ max_dead_jobs: 10_000,
25
+ stats_interval: 10,
26
+ poller_enabled: true,
27
+ poll_interval: 5,
28
+ dead_timeout: 180 * 24 * 60 * 60, # 6 months in seconds
29
+ # client settings
30
+ client_concurrency: 10,
31
+ client_redis_pool: nil,
32
+ client_async: false,
33
+ }
34
+
35
+ def initialize(options = {})
36
+ @options = DEFAULTS.merge(options)
37
+ @options[:redis_url] = ENV['REDIS_URL'] if @options[:redis_url].nil? && ENV['REDIS_URL']
38
+ @options[:queues] ||= [DEFAULT_QUEUE]
39
+ @directory = {}
40
+ end
41
+
42
+ def queue_prefix
43
+ QUEUE_PREFIX
44
+ end
45
+
46
+ def retries_set
47
+ RETRIES_SET
48
+ end
49
+
50
+ def scheduled_set
51
+ SCHEDULED_SET
52
+ end
53
+
54
+ def dead_set
55
+ DEAD_SET
56
+ end
57
+
58
+ def default_queue
59
+ DEFAULT_QUEUE
60
+ end
61
+
62
+ # jiggler main process prefix
63
+ def server_prefix
64
+ SERVER_PREFIX
65
+ end
66
+
67
+ def processed_counter
68
+ PROCESSED_COUNTER
69
+ end
70
+
71
+ def failures_counter
72
+ FAILURES_COUNTER
73
+ end
74
+
75
+ def process_scan_key
76
+ @process_scan_key ||= "#{server_prefix}*"
77
+ end
78
+
79
+ def queue_scan_key
80
+ @queue_scan_key ||= "#{queue_prefix}*"
81
+ end
82
+
83
+ def prefixed_queues
84
+ @prefixed_queues ||= @options[:queues].map do |name|
85
+ "#{QUEUE_PREFIX}#{name}"
86
+ end
87
+ end
88
+
89
+ def with_async_redis
90
+ Async do
91
+ redis_pool.acquire do |conn|
92
+ yield conn
93
+ end
94
+ end
95
+ end
96
+
97
+ def with_sync_redis
98
+ Sync do
99
+ redis_pool.acquire do |conn|
100
+ yield conn
101
+ end
102
+ end
103
+ end
104
+
105
+ def redis_options
106
+ @redis_options ||= begin
107
+ opts = @options.slice(
108
+ :concurrency,
109
+ :redis_url
110
+ )
111
+
112
+ opts[:concurrency] += 2 # monitor + safety margin
113
+ opts[:concurrency] += 1 if @options[:poller_enabled]
114
+ opts[:async] = true
115
+
116
+ opts
117
+ end
118
+ end
119
+
120
+ def client_redis_options
121
+ @client_redis_options ||= begin
122
+ opts = @options.slice(
123
+ :redis_url,
124
+ :client_redis_pool
125
+ )
126
+
127
+ opts[:concurrency] = @options[:client_concurrency]
128
+ opts[:async] = @options[:client_async]
129
+ opts
130
+ end
131
+ end
132
+
133
+ def redis_pool
134
+ @redis_pool ||= Jiggler::RedisStore.new(redis_options).pool
135
+ end
136
+
137
+ def client_redis_pool
138
+ @client_redis_pool ||= begin
139
+ @options[:client_redis_pool] || Jiggler::RedisStore.new(client_redis_options).pool
140
+ end
141
+ end
142
+
143
+ def client_redis_pool=(new_pool)
144
+ @client_redis_pool = new_pool
145
+ end
146
+
147
+ def cleaner
148
+ @cleaner ||= Jiggler::Cleaner.new(self)
149
+ end
150
+
151
+ def summary
152
+ @summary ||= Jiggler::Summary.new(self)
153
+ end
154
+
155
+ def logger=(new_logger)
156
+ @logger = new_logger
157
+ end
158
+
159
+ def logger
160
+ @logger ||= ::Logger.new(STDOUT, level: :info)
161
+ end
162
+
163
+ def_delegators :@options, :[], :[]=, :fetch, :key?, :has_key?, :merge!, :delete, :slice
164
+ end
165
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+ require 'securerandom'
5
+
6
+ module Jiggler
7
+ def self.config
8
+ @config ||= Jiggler::Config.new
9
+ end
10
+
11
+ def self.logger
12
+ config.logger
13
+ end
14
+
15
+ def self.configure(&block)
16
+ block.call(config)
17
+ end
18
+
19
+ def self.summary
20
+ config.summary.all
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ class UnknownJobError < StandardError; end
5
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ module Job
5
+ module ClassMethods
6
+ def enqueue(*args)
7
+ Enqueuer.new(self).enqueue(*args)
8
+ end
9
+
10
+ def enqueue_in(seconds, *args)
11
+ Enqueuer.new(self).enqueue_in(seconds, *args)
12
+ end
13
+
14
+ # MyJob.with_options(queue: 'custom', retries: 3).enqueue(*args)
15
+ def with_options(options)
16
+ Enqueuer.new(self, options)
17
+ end
18
+
19
+ def enqueue_bulk(args_arr)
20
+ Enqueuer.new(self).enqueue_bulk(args_arr)
21
+ end
22
+
23
+ def queue
24
+ @queue || Jiggler::Config::DEFAULT_QUEUE
25
+ end
26
+
27
+ def retry_queue
28
+ @retry_queue || Jiggler::Config::DEFAULT_QUEUE
29
+ end
30
+
31
+ def retries
32
+ @retries || 0
33
+ end
34
+
35
+ def job_options(queue: nil, retries: nil, retry_queue: nil)
36
+ @queue = queue || Jiggler::Config::DEFAULT_QUEUE
37
+ @retries = retries || 0
38
+ @retry_queue = retry_queue || queue
39
+ end
40
+ end
41
+
42
+ class Enqueuer
43
+ def initialize(klass, options = {})
44
+ @options = options
45
+ @klass = klass
46
+ end
47
+
48
+ def with_options(options)
49
+ @options.merge(options)
50
+ self
51
+ end
52
+
53
+ def enqueue(*args)
54
+ Jiggler.config.client_redis_pool.acquire do |conn|
55
+ conn.call('LPUSH', list_name, job_args(args))
56
+ end
57
+ end
58
+
59
+ def enqueue_bulk(args_arr)
60
+ Jiggler.config.client_redis_pool.acquire do |conn|
61
+ conn.pipelined do |pipeline|
62
+ args_arr.each do |args|
63
+ pipeline.call('LPUSH', list_name, job_args(args))
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ def enqueue_in(seconds, *args)
70
+ timestamp = Time.now.to_f + seconds
71
+ Jiggler.config.client_redis_pool.acquire do |conn|
72
+ conn.call(
73
+ 'ZADD',
74
+ config.scheduled_set,
75
+ timestamp,
76
+ job_args(args)
77
+ )
78
+ end
79
+ end
80
+
81
+ def list_name
82
+ "#{config.queue_prefix}#{@options.fetch(:queue, @klass.queue)}"
83
+ end
84
+
85
+ def job_args(raw_args)
86
+ Oj.dump({ name: @klass.name, args: raw_args, **job_options }, mode: :compat)
87
+ end
88
+
89
+ def job_options
90
+ retries = @options.fetch(:retries, @klass.retries)
91
+ jid = @options.fetch(:jid, SecureRandom.hex(8))
92
+ { retries: retries, jid: jid }
93
+ end
94
+
95
+ def config
96
+ @config ||= Jiggler.config
97
+ end
98
+ end
99
+
100
+ def self.included(base)
101
+ base.extend(ClassMethods)
102
+ end
103
+
104
+ def enqueue(*args)
105
+ Enqueuer.new(self.class).enqueue(*args)
106
+ end
107
+
108
+ def enqueue_in(seconds, *args)
109
+ Enqueuer.new(self.class).enqueue_in(seconds, *args)
110
+ end
111
+
112
+ def perform(**args)
113
+ raise "#{self.class} must implement 'perform' method"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ class Launcher
5
+ include Support::Helper
6
+
7
+ attr_reader :config
8
+
9
+ def initialize(config)
10
+ @done = false
11
+ @config = config
12
+ end
13
+
14
+ def start
15
+ poller.start if config[:poller_enabled]
16
+ monitor.start
17
+ manager.start
18
+ end
19
+
20
+ def suspend
21
+ return if @done
22
+
23
+ @done = true
24
+ manager.suspend
25
+
26
+ poller.terminate if config[:poller_enabled]
27
+ monitor.terminate
28
+ end
29
+
30
+ def stop
31
+ suspend
32
+ manager.terminate
33
+ end
34
+
35
+ private
36
+
37
+ def uuid
38
+ @uuid ||= begin
39
+ data_str = [
40
+ SecureRandom.hex(6),
41
+ config[:concurrency],
42
+ config[:timeout],
43
+ config[:queues].join(','),
44
+ config[:poller_enabled] ? '1' : '0',
45
+ Time.now.to_i,
46
+ Process.pid,
47
+ ENV['DYNO'] || Socket.gethostname
48
+ ].join(':')
49
+ "#{config.server_prefix}#{data_str}"
50
+ end
51
+ end
52
+
53
+ def collection
54
+ @collection ||= Stats::Collection.new(uuid)
55
+ end
56
+
57
+ def manager
58
+ @manager ||= Manager.new(config, collection)
59
+ end
60
+
61
+ def poller
62
+ @poller ||= Scheduled::Poller.new(config)
63
+ end
64
+
65
+ def monitor
66
+ @monitor ||= Stats::Monitor.new(config, collection)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jiggler
4
+ class Manager
5
+ include Support::Helper
6
+
7
+ def initialize(config, collection)
8
+ @workers = Set.new
9
+ @done = false
10
+ @config = config
11
+ @timeout = @config[:timeout]
12
+ @collection = collection
13
+ @config[:concurrency].times do
14
+ @workers << init_worker
15
+ end
16
+ end
17
+
18
+ def start
19
+ @workers.each(&:run)
20
+ end
21
+
22
+ def suspend
23
+ return if @done
24
+
25
+ @done = true
26
+ @workers.each(&:suspend)
27
+ end
28
+
29
+ def terminate
30
+ suspend
31
+ schedule_shutdown
32
+ wait_for_workers
33
+ end
34
+
35
+ private
36
+
37
+ def wait_for_workers
38
+ logger.info('Waiting for workers to finish...')
39
+ @workers.each(&:wait)
40
+ @shutdown_task.stop
41
+ end
42
+
43
+ def schedule_shutdown
44
+ @shutdown_task = Async do
45
+ sleep(@timeout)
46
+
47
+ next if @workers.empty?
48
+
49
+ hard_shutdown
50
+ end
51
+ end
52
+
53
+ def init_worker
54
+ Jiggler::Worker.new(
55
+ @config, @collection, &method(:process_worker_result)
56
+ )
57
+ end
58
+
59
+ def process_worker_result(worker, reason = nil)
60
+ @workers.delete(worker)
61
+ unless @done
62
+ new_worker = init_worker
63
+ @workers << new_worker
64
+ new_worker.run
65
+ end
66
+ end
67
+
68
+ def hard_shutdown
69
+ logger.warn('Hard shutdown, terminating workers...')
70
+ @workers.each(&:terminate)
71
+ end
72
+ end
73
+ end