qu-scheduler 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. data/MIT-LICENSE +2 -1
  2. data/README.md +191 -0
  3. data/lib/qu-scheduler.rb +1 -4
  4. data/lib/qu-scheduler/tasks.rb +35 -0
  5. data/lib/qu/extensions/scheduler.rb +102 -0
  6. data/lib/qu/extensions/scheduler/redis.rb +199 -0
  7. data/lib/qu/scheduler.rb +227 -0
  8. data/lib/qu/scheduler/version.rb +2 -2
  9. data/test/delayed_queue_test.rb +277 -0
  10. data/test/redis-test.conf +115 -0
  11. data/test/scheduler_args_test.rb +156 -0
  12. data/test/scheduler_hooks_test.rb +51 -0
  13. data/test/scheduler_test.rb +181 -0
  14. data/test/test_helper.rb +91 -7
  15. metadata +64 -76
  16. data/README.rdoc +0 -7
  17. data/lib/tasks/qu-scheduler_tasks.rake +0 -4
  18. data/test/dummy/Rakefile +0 -7
  19. data/test/dummy/app/assets/javascripts/application.js +0 -9
  20. data/test/dummy/app/assets/stylesheets/application.css +0 -7
  21. data/test/dummy/app/controllers/application_controller.rb +0 -3
  22. data/test/dummy/app/helpers/application_helper.rb +0 -2
  23. data/test/dummy/app/views/layouts/application.html.erb +0 -14
  24. data/test/dummy/config.ru +0 -4
  25. data/test/dummy/config/application.rb +0 -45
  26. data/test/dummy/config/boot.rb +0 -10
  27. data/test/dummy/config/database.yml +0 -25
  28. data/test/dummy/config/environment.rb +0 -5
  29. data/test/dummy/config/environments/development.rb +0 -30
  30. data/test/dummy/config/environments/production.rb +0 -60
  31. data/test/dummy/config/environments/test.rb +0 -39
  32. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  33. data/test/dummy/config/initializers/inflections.rb +0 -10
  34. data/test/dummy/config/initializers/mime_types.rb +0 -5
  35. data/test/dummy/config/initializers/secret_token.rb +0 -7
  36. data/test/dummy/config/initializers/session_store.rb +0 -8
  37. data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
  38. data/test/dummy/config/locales/en.yml +0 -5
  39. data/test/dummy/config/routes.rb +0 -58
  40. data/test/dummy/db/test.sqlite3 +0 -0
  41. data/test/dummy/log/test.log +0 -0
  42. data/test/dummy/public/404.html +0 -26
  43. data/test/dummy/public/422.html +0 -26
  44. data/test/dummy/public/500.html +0 -26
  45. data/test/dummy/public/favicon.ico +0 -0
  46. data/test/dummy/script/rails +0 -6
data/MIT-LICENSE CHANGED
@@ -1,4 +1,5 @@
1
- Copyright 2011 YOURNAME
1
+ Copyright 2011 Morton Jonuschat
2
+ Some parts copyright 2010 Ben VandenBos
2
3
 
3
4
  Permission is hereby granted, free of charge, to any person obtaining
4
5
  a copy of this software and associated documentation files (the
data/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # Qu::Scheduler
2
+
3
+ ## Description
4
+
5
+ qu-scheduler is an extension to [Qu](http://github.com/bkeepers/qu)
6
+ that adds support for queueing jobs in the future.
7
+
8
+ Currently qu-scheduler only works with qu-redis and requires Redis 2.0 or newer.
9
+
10
+ Job scheduling is supported in two different ways: Recurring (scheduled) and
11
+ Delayed.
12
+
13
+ Scheduled jobs are like cron jobs, recurring on a regular basis. Delayed
14
+ jobs are Qu jobs that you want to run at some point in the future.
15
+ The syntax is pretty explanatory:
16
+
17
+ Qu.enqueue_in(5.days, SendFollowupEmail) # run a job in 5 days
18
+ # or
19
+ Qu.enqueue_at(5.days.from_now, SomeJob) # run SomeJob at a specific time
20
+
21
+ ## Installation
22
+
23
+ # Rails 3.x: add it to your Gemfile
24
+ gem 'qu-scheduler'
25
+
26
+ There are just a single thing `qu-scheduler` needs to know about in order
27
+ to do it's jobs: the schedule. The easiest way to configure these things
28
+ is via the rake task. By default, `qu-scheduler` depends on the "qu:setup"
29
+ rake task. Since you probably already have this task, lets just put our
30
+ configuration there. `qu-scheduler` pretty much inherits everything else
31
+ from Qu.
32
+
33
+ # Qu tasks
34
+ require 'qu/tasks'
35
+ require 'qu-scheduler/tasks'
36
+
37
+ namespace :qu do
38
+ task :setup do
39
+ require 'qu'
40
+ require 'qu-scheduler'
41
+
42
+ # If you want to be able to dynamically change the schedule,
43
+ # uncomment this line. A dynamic schedule can be updated via the
44
+ # Qu::Scheduler.set_schedule (and remove_schedule) methods.
45
+ # When dynamic is set to true, the scheduler process looks for
46
+ # schedule changes and applies them on the fly.
47
+ # Note: This feature is still under development
48
+ # Qu::Scheduler.dynamic = true
49
+
50
+ # The schedule doesn't need to be stored in a YAML, it just needs to
51
+ # be a hash. YAML is usually the easiest.
52
+ Qu.schedule = YAML.load_file(Rails.root.join('config', 'your_resque_schedule.yml'))
53
+
54
+ # If your don't depend on your application environment you need to
55
+ # require your jobs here. Qu determines the queue exclusively from the
56
+ # class, so we need to have access to them.
57
+ require 'jobs'
58
+ end
59
+ end
60
+
61
+ The scheduler process is just a rake task which is responsible for both
62
+ queueing jobs from the schedule and polling the delayed queue for jobs
63
+ ready to be pushed on to the work queues. For obvious reasons, this process
64
+ never exits.
65
+
66
+ $ bundle exec rake qu:scheduler
67
+
68
+ NOTE: You DO NOT want to run more than one instance of the scheduler. Doing
69
+ so will result in the same job being queued multiple times. You only need one
70
+ instance of the scheduler running per application, regardless of number of servers.
71
+
72
+ If the scheduler process goes down for whatever reason, the delayed items
73
+ that should have fired during the outage will fire once the scheduler process
74
+ is started back up again (even if it is on a new machine). Missed scheduled
75
+ jobs, however, will not fire upon recovery of the scheduler process.
76
+
77
+ ## Delayed jobs
78
+
79
+ Delayed jobs are one-off jobs that you want to be put into a queue at some point
80
+ in the future. The classic example is sending email:
81
+
82
+ Qu.enqueue_in(5.days, SendFollowUpEmail, current_user.id)
83
+
84
+ This will store the job for 5 days in the Qu delayed queue at which time
85
+ the scheduler process will pull it from the delayed queue and put it in the
86
+ appropriate work queue for the given job. It will then be processed as soon as
87
+ a worker is available (just like any other Qu job).
88
+
89
+ NOTE: The job does not fire **exactly** at the time supplied. Rather, once that
90
+ time is in the past, the job moves from the delayed queue to the actual work
91
+ queue and will be completed as workers as free to process it.
92
+
93
+ Also supported is `Qu.enqueue_at` which takes a timestamp to queue the job.
94
+
95
+ The delayed queue is stored in redis and is persisted in the same way the
96
+ standard Qu jobs are persisted (redis writing to disk). Delayed jobs differ
97
+ from scheduled jobs in that if your scheduler process is down or workers are
98
+ down when a particular job is supposed to be processed, they will simply "catch up"
99
+ once they are started again. Jobs are guaranteed to run (provided they make it
100
+ into the delayed queue) after their given queue_at time has passed.
101
+
102
+ Your jobs can specify one or more `before_schedule` and `after_schedule` hooks,
103
+ to be run before or after scheduling. If *any* of your `before_schedule` hooks
104
+ returns `false`, the job will *not* be scheduled and your `after_schedule` hooks
105
+ will *not* be run.
106
+
107
+ One other thing to note is that insertion into the delayed queue is O(log(n))
108
+ since the jobs are stored in a redis sorted set (zset). I can't imagine this
109
+ being an issue for someone since redis is stupidly fast even at log(n), but full
110
+ disclosure is always best.
111
+
112
+ ### Removing Delayed jobs
113
+
114
+ If you have the need to cancel a delayed job, you can do it like this:
115
+
116
+ # after you've enqueued a job like:
117
+ Qu.enqueue_at(5.days.from_now, SendFollowUpEmail, current_user.id)
118
+ # remove the job with exactly the same parameters:
119
+ Qu.remove_delayed(SendFollowUpEmail, current_user.id)
120
+
121
+ ## Scheduled Jobs (Recurring Jobs)
122
+
123
+ Scheduled (or recurring) jobs are logically no different than a standard cron
124
+ job. They are jobs that run based on a fixed schedule which is set at
125
+ startup.
126
+
127
+ The schedule is a list of job classes with arguments and a schedule frequency
128
+ (in crontab syntax). The schedule is just a hash, but is most likely stored in
129
+ a YAML like this:
130
+
131
+ queue_documents_for_indexing:
132
+ cron: "0 0 * * *"
133
+ # you can use rufus-scheduler "every" syntax in place of cron if you prefer
134
+ # every: 1hr
135
+ klass: QueueDocuments
136
+ args:
137
+ description: "This job queues all content for indexing in solr"
138
+
139
+ clear_leaderboards_contributors:
140
+ cron: "30 6 * * 1"
141
+ klass: ClearLeaderboards
142
+ args: contributors
143
+ description: "This job resets the weekly leaderboard for contributions"
144
+
145
+ NOTE: Six parameter cron's are also supported (as they supported by
146
+ rufus-scheduler which powers the resque-scheduler process). This allows you
147
+ to schedule jobs per second (ie: "30 * * * * *" would fire a job every 30
148
+ seconds past the minute).
149
+
150
+ A big shout out to [rufus-scheduler](http://github.com/jmettraux/rufus-scheduler)
151
+ for handling the heavy lifting of the actual scheduling engine.
152
+
153
+ ## Running in the background
154
+
155
+ (Only supported with ruby >= 1.9). There are scenarios where it's helpful for
156
+ the resque worker to run itself in the background (usually in combination with
157
+ PIDFILE). Use the BACKGROUND option so that rake will return as soon as the
158
+ worker is started.
159
+
160
+ $ PIDFILE=./qu-scheduler.pid BACKGROUND=yes bundle exec rake qu:scheduler
161
+
162
+ ## Additional information
163
+
164
+ by Ben VandenBos to the Qu queuing library.
165
+
166
+ ## Note on Patches / Pull Requests
167
+
168
+ * Fork the project.
169
+ * Make your feature addition or bug fix.
170
+ * Add tests for it. This is important so I don't break it in a future version unintentionally.
171
+ * Commit, do not mess with rakefile, version, or history.
172
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
173
+ * Send me a pull request. Bonus points for topic branches.
174
+
175
+ ## Credits
176
+
177
+ This work is a port of [resque-scheduler](https://github.com/bvandenbos/resque-scheduler) by Ben VandenBos.
178
+ Modified to work with the Qu queueing library by Morton Jonuschat.
179
+
180
+ ## Maintainers
181
+
182
+ * [Morton Jonuschat](https://github.com/yabawock)
183
+
184
+ ## License
185
+
186
+ MIT License
187
+
188
+ ## Copyright
189
+
190
+ Copyright 2011 Morton Jonuschat
191
+ Some parts copyright 2010 Ben VandenBos
data/lib/qu-scheduler.rb CHANGED
@@ -1,4 +1 @@
1
- module Qu
2
- module Scheduler
3
- end
4
- end
1
+ require 'qu/scheduler'
@@ -0,0 +1,35 @@
1
+ require 'qu/tasks'
2
+
3
+ namespace :qu do
4
+ desc "Start Qu Scheduler"
5
+ task :scheduler => :scheduler_setup do
6
+ require 'qu'
7
+ require 'qu-scheduler'
8
+
9
+ if ENV['BACKGROUND']
10
+ unless Process.respond_to?('daemon')
11
+ abort "Environment variable BACKGROUND is set, which requires ruby >= 1.9"
12
+ end
13
+ Process.daemon(true)
14
+ end
15
+
16
+ File.open(ENV['PIDFILE'], 'w') { |f| f << Process.pid.to_s } if ENV['PIDFILE']
17
+
18
+ Qu::Scheduler.dynamic = true if ENV['DYNAMIC_SCHEDULE']
19
+ Qu::Scheduler.run
20
+ end
21
+
22
+ task :scheduler_setup do
23
+ if ENV['INITIALIZER_PATH']
24
+ load ENV['INITIALIZER_PATH'].to_s.strip
25
+ else
26
+ Rake::Task['qu:setup'].invoke
27
+ end
28
+ end
29
+
30
+ # No-op task in case it doesn't already exist
31
+ task :setup
32
+ end
33
+
34
+ # Convenience tasks compatibility
35
+ task 'resque:scheduler' => 'qu:scheduler'
@@ -0,0 +1,102 @@
1
+ module Qu
2
+ module Extensions
3
+ module Scheduler
4
+ autoload :Redis, 'qu/extensions/scheduler/redis'
5
+
6
+ #
7
+ # Accepts a new schedule configuration of the form:
8
+ #
9
+ # {'some_name' => {"cron" => "5/* * * *",
10
+ # "class" => "DoSomeWork",
11
+ # "args" => "work on this string",
12
+ # "description" => "this thing works it"s butter off"},
13
+ # ...}
14
+ #
15
+ # 'some_name' can be anything and is used only to describe and reference
16
+ # the scheduled job
17
+ #
18
+ # :cron can be any cron scheduling string :job can be any resque job class
19
+ #
20
+ # :every can be used in lieu of :cron. see rufus-scheduler's 'every' usage
21
+ # for valid syntax. If :cron is present it will take precedence over :every.
22
+ #
23
+ # :class must be a resque worker class
24
+ #
25
+ # :args can be any yaml which will be converted to a ruby literal and
26
+ # passed in a params. (optional)
27
+ #
28
+ # :rails_envs is the list of envs where the job gets loaded. Envs are
29
+ # comma separated (optional)
30
+ #
31
+ # :description is just that, a description of the job (optional). If
32
+ # params is an array, each element in the array is passed as a separate
33
+ # param, otherwise params is passed in as the only parameter to perform.
34
+ def schedule=(schedule_hash)
35
+ if Qu::Scheduler.dynamic
36
+ schedule_hash.each do |name, job_spec|
37
+ backend.set_schedule(name, job_spec)
38
+ end
39
+ end
40
+ @schedule = schedule_hash
41
+ end
42
+
43
+ # Returns the schedule hash
44
+ def schedule
45
+ @schedule ||= {}
46
+ end
47
+
48
+ # reloads the schedule from redis
49
+ def reload_schedule!
50
+ @schedule = backend.get_schedules
51
+ end
52
+
53
+ # This method is nearly identical to +enqueue+ only it also
54
+ # takes a timestamp which will be used to schedule the job
55
+ # for queueing. Until timestamp is in the past, the job will
56
+ # sit in the schedule list.
57
+ def enqueue_at(timestamp, klass, *args)
58
+ before_hooks = before_schedule_hooks(klass).collect do |hook|
59
+ klass.send(hook,*args)
60
+ end
61
+ return false if before_hooks.any? { |result| result == false }
62
+
63
+ backend.delayed_push(timestamp, Payload.new(:klass => klass, :args => args))
64
+
65
+ after_schedule_hooks(klass).collect do |hook|
66
+ klass.send(hook,*args)
67
+ end
68
+ end
69
+
70
+ # Identical to enqueue_at but takes number_of_seconds_from_now
71
+ # instead of a timestamp.
72
+ def enqueue_in(number_of_seconds_from_now, klass, *args)
73
+ enqueue_at(Time.now + number_of_seconds_from_now, klass, *args)
74
+ end
75
+
76
+ # Returns an array of delayed items for the given timestamp
77
+ def delayed_timestamp_peek(timestamp, start, count)
78
+ if 1 == count
79
+ r = backend.list_range "delayed:#{timestamp.to_i}", start, count
80
+ r.nil? ? [] : [r]
81
+ else
82
+ backend.list_range "delayed:#{timestamp.to_i}", start, count
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def before_schedule_hooks(klass)
89
+ klass.methods.grep(/^before_schedule/).sort
90
+ end
91
+
92
+ def after_schedule_hooks(klass)
93
+ klass.methods.grep(/^after_schedule/).sort
94
+ end
95
+
96
+ end
97
+ end
98
+ end
99
+ Qu.send :extend, Qu::Extensions::Scheduler
100
+ if defined? Qu::Backend::Redis
101
+ Qu::Backend::Redis.send :include, Qu::Extensions::Scheduler::Redis
102
+ end
@@ -0,0 +1,199 @@
1
+ module Qu
2
+ module Extensions
3
+ module Scheduler
4
+ module Redis
5
+ # Does the dirty work of fetching a range of items from a Redis list
6
+ # and converting them into Ruby objects.
7
+ def list_range(key, start = 0, count = 1)
8
+ if count == 1
9
+ decode redis.lindex(key, start)
10
+ else
11
+ Array(redis.lrange(key, start, start+count-1)).map do |item|
12
+ decode item
13
+ end
14
+ end
15
+ end
16
+
17
+ # gets the schedule as it exists in redis
18
+ def get_schedules
19
+ if redis.exists(:schedules)
20
+ redis.hgetall(:schedules).tap do |h|
21
+ h.each do |name, config|
22
+ h[name] = decode(config)
23
+ end
24
+ end
25
+ else
26
+ nil
27
+ end
28
+ end
29
+
30
+ # Create or update a schedule with the provided name and configuration.
31
+ #
32
+ # Note: values for class and custom_job_class need to be strings,
33
+ # not constants.
34
+ #
35
+ # Qu.set_schedule('some_job', { :class => 'SomeJob',
36
+ # :every => '15mins',
37
+ # :queue => 'high',
38
+ # :args => '/tmp/poop' })
39
+ def set_schedule(name, config)
40
+ existing_config = get_schedule(name)
41
+ unless existing_config && existing_config == config
42
+ redis.hset(:schedules, name, encode(config))
43
+ redis.sadd(:schedules_changed, name)
44
+ end
45
+ config
46
+ end
47
+
48
+ # retrieve the schedule configuration for the given name
49
+ def get_schedule(name)
50
+ decode(redis.hget(:schedules, name))
51
+ end
52
+
53
+ # remove a given schedule by name
54
+ def remove_schedule(name)
55
+ redis.hdel(:schedules, name)
56
+ redis.sadd(:schedules_changed, name)
57
+ end
58
+
59
+ def update_schedule
60
+ if redis.scard(:schedules_changed) > 0
61
+ Qu::Scheduler.set_process_title "updating schedule"
62
+ Qu.reload_schedule!
63
+ while schedule_name = redis.spop(:schedules_changed)
64
+ if Qu.schedule.keys.include?(schedule_name)
65
+ Qu::Scheduler.unschedule_job(schedule_name)
66
+ Qu::Scheduler.load_schedule_job(schedule_name, Qu.schedule[schedule_name])
67
+ else
68
+ Qu::Scheduler.unschedule_job(schedule_name)
69
+ end
70
+ end
71
+ Qu::Scheduler.set_process_title "running"
72
+ end
73
+ end
74
+
75
+ # Pulls the schedule from Qu.schedule and loads it into the
76
+ # rufus scheduler instance
77
+ def load_schedule!
78
+ Qu::Scheduler.set_process_title "loading schedule"
79
+
80
+ # Need to load the schedule from redis for the first time if dynamic
81
+ Qu.reload_schedule! if Qu::Scheduler.dynamic
82
+
83
+ Qu.logger.warning("Schedule empty! Set Qu.schedule") if Qu.schedule.empty?
84
+
85
+ @@scheduled_jobs = {}
86
+
87
+ Qu.schedule.each do |name, config|
88
+ Qu::Scheduler.load_schedule_job(name, config)
89
+ end
90
+ redis.del(:schedules_changed)
91
+ Qu::Scheduler.set_process_title "running"
92
+ end
93
+
94
+ # Used internally to stuff the item into the schedule sorted list.
95
+ # +timestamp+ can be either in seconds or a datetime object
96
+ # Insertion if O(log(n)).
97
+ # Returns true if it's the first job to be scheduled at that time, else false
98
+ def delayed_push(timestamp, payload)
99
+ # First add this item to the list for this timestamp
100
+ redis.rpush("delayed:#{timestamp.to_i}", encode('klass' => payload.klass.to_s, 'args' => payload.args))
101
+
102
+ # Now, add this timestamp to the zsets. The score and the value are
103
+ # the same since we'll be querying by timestamp, and we don't have
104
+ # anything else to store.
105
+ redis.zadd :delayed_queue_schedule, timestamp.to_i, timestamp.to_i
106
+ end
107
+
108
+ # Returns an array of timestamps based on start and count
109
+ def delayed_queue_peek(start, count)
110
+ Array(redis.zrange(:delayed_queue_schedule, start, start+count)).collect{|x| x.to_i}
111
+ end
112
+
113
+ # Returns the size of the delayed queue schedule
114
+ def delayed_queue_schedule_size
115
+ redis.zcard :delayed_queue_schedule
116
+ end
117
+
118
+ # Returns the number of jobs for a given timestamp in the delayed queue schedule
119
+ def delayed_timestamp_size(timestamp)
120
+ redis.llen("delayed:#{timestamp.to_i}").to_i
121
+ end
122
+
123
+ # Returns the next delayed queue timestamp
124
+ # (don't call directly)
125
+ def next_delayed_timestamp(at_time=nil)
126
+ items = redis.zrangebyscore :delayed_queue_schedule, '-inf', (at_time || Time.now).to_i, :limit => [0, 1]
127
+ timestamp = items.nil? ? nil : Array(items).first
128
+ timestamp.to_i unless timestamp.nil?
129
+ end
130
+
131
+ # Returns the next item to be processed for a given timestamp, nil if
132
+ # done. (don't call directly)
133
+ # +timestamp+ can either be in seconds or a datetime
134
+ def next_item_for_timestamp(timestamp)
135
+ key = "delayed:#{timestamp.to_i}"
136
+
137
+ item = decode redis.lpop(key)
138
+
139
+ # If the list is empty, remove it.
140
+ clean_up_timestamp(key, timestamp)
141
+ item
142
+ end
143
+
144
+ # Clears all jobs created with enqueue_at or enqueue_in
145
+ def reset_delayed_queue
146
+ Array(redis.zrange(:delayed_queue_schedule, 0, -1)).each do |item|
147
+ redis.del "delayed:#{item}"
148
+ end
149
+
150
+ redis.del :delayed_queue_schedule
151
+ end
152
+
153
+ # Given an encoded item, remove it from the delayed_queue
154
+ #
155
+ # This method is potentially very expensive since it needs to scan
156
+ # through the delayed queue for every timestamp.
157
+ def remove_delayed(klass, *args)
158
+ destroyed = 0
159
+ search = encode('klass' => klass.to_s, 'args' => args)
160
+ Array(redis.keys("delayed:*")).each do |key|
161
+ destroyed += redis.lrem key, 0, search
162
+ end
163
+ destroyed
164
+ end
165
+
166
+ # Given a timestamp and job (klass + args) it removes all instances and
167
+ # returns the count of jobs removed.
168
+ #
169
+ # O(N) where N is the number of jobs scheduled to fire at the given
170
+ # timestamp
171
+ def remove_delayed_job_from_timestamp(timestamp, klass, *args)
172
+ key = "delayed:#{timestamp.to_i}"
173
+ count = redis.lrem key, 0, encode('klass' => klass.to_s, 'args' => args)
174
+ clean_up_timestamp(key, timestamp)
175
+ count
176
+ end
177
+
178
+ def count_all_scheduled_jobs
179
+ total_jobs = 0
180
+ Array(redis.zrange(:delayed_queue_schedule, 0, -1)).each do |timestamp|
181
+ total_jobs += redis.llen("delayed:#{timestamp}").to_i
182
+ end
183
+ total_jobs
184
+ end
185
+
186
+ private
187
+
188
+ def clean_up_timestamp(key, timestamp)
189
+ # If the list is empty, remove it.
190
+ if 0 == redis.llen(key).to_i
191
+ redis.del key
192
+ redis.zrem :delayed_queue_schedule, timestamp.to_i
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+