rufus-scheduler 2.0.24 → 3.0.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 (53) hide show
  1. data/CHANGELOG.txt +6 -0
  2. data/CREDITS.txt +4 -0
  3. data/README.md +1064 -0
  4. data/Rakefile +1 -4
  5. data/TODO.txt +145 -55
  6. data/lib/rufus/scheduler.rb +502 -26
  7. data/lib/rufus/{sc → scheduler}/cronline.rb +46 -17
  8. data/lib/rufus/{sc/version.rb → scheduler/job_array.rb} +56 -4
  9. data/lib/rufus/scheduler/jobs.rb +548 -0
  10. data/lib/rufus/scheduler/util.rb +318 -0
  11. data/rufus-scheduler.gemspec +30 -4
  12. data/spec/cronline_spec.rb +29 -8
  13. data/spec/error_spec.rb +116 -0
  14. data/spec/job_array_spec.rb +39 -0
  15. data/spec/job_at_spec.rb +58 -0
  16. data/spec/job_cron_spec.rb +67 -0
  17. data/spec/job_every_spec.rb +71 -0
  18. data/spec/job_in_spec.rb +20 -0
  19. data/spec/job_interval_spec.rb +68 -0
  20. data/spec/job_repeat_spec.rb +308 -0
  21. data/spec/job_spec.rb +387 -115
  22. data/spec/lockfile_spec.rb +61 -0
  23. data/spec/parse_spec.rb +203 -0
  24. data/spec/schedule_at_spec.rb +129 -0
  25. data/spec/schedule_cron_spec.rb +66 -0
  26. data/spec/schedule_every_spec.rb +109 -0
  27. data/spec/schedule_in_spec.rb +80 -0
  28. data/spec/schedule_interval_spec.rb +128 -0
  29. data/spec/scheduler_spec.rb +831 -124
  30. data/spec/spec_helper.rb +65 -0
  31. data/spec/threads_spec.rb +75 -0
  32. metadata +64 -59
  33. data/README.rdoc +0 -661
  34. data/lib/rufus/otime.rb +0 -3
  35. data/lib/rufus/sc/jobqueues.rb +0 -160
  36. data/lib/rufus/sc/jobs.rb +0 -471
  37. data/lib/rufus/sc/rtime.rb +0 -363
  38. data/lib/rufus/sc/scheduler.rb +0 -636
  39. data/spec/at_in_spec.rb +0 -47
  40. data/spec/at_spec.rb +0 -125
  41. data/spec/blocking_spec.rb +0 -64
  42. data/spec/cron_spec.rb +0 -134
  43. data/spec/every_spec.rb +0 -304
  44. data/spec/exception_spec.rb +0 -113
  45. data/spec/in_spec.rb +0 -150
  46. data/spec/mutex_spec.rb +0 -159
  47. data/spec/rtime_spec.rb +0 -137
  48. data/spec/schedulable_spec.rb +0 -97
  49. data/spec/spec_base.rb +0 -87
  50. data/spec/stress_schedule_unschedule_spec.rb +0 -159
  51. data/spec/timeout_spec.rb +0 -148
  52. data/test/kjw.rb +0 -113
  53. data/test/t.rb +0 -20
@@ -22,10 +22,8 @@
22
22
  # Made in Japan.
23
23
  #++
24
24
 
25
- require 'tzinfo'
26
25
 
27
-
28
- module Rufus
26
+ class Rufus::Scheduler
29
27
 
30
28
  #
31
29
  # A 'cron line' is a line in the sense of a crontab
@@ -33,9 +31,6 @@ module Rufus
33
31
  #
34
32
  class CronLine
35
33
 
36
- DAY_S = 24 * 3600
37
- WEEK_S = 7 * DAY_S
38
-
39
34
  # The string used for creating this cronline instance.
40
35
  #
41
36
  attr_reader :original
@@ -51,7 +46,9 @@ module Rufus
51
46
 
52
47
  def initialize(line)
53
48
 
54
- super()
49
+ raise ArgumentError.new(
50
+ "not a string: #{line.inspect}"
51
+ ) unless line.is_a?(String)
55
52
 
56
53
  @original = line
57
54
 
@@ -123,12 +120,15 @@ module Rufus
123
120
  #
124
121
  # (Thanks to K Liu for the note and the examples)
125
122
  #
126
- def next_time(now=Time.now)
123
+ def next_time(from=Time.now)
124
+
125
+ time = @timezone ? @timezone.utc_to_local(from.getutc) : from
127
126
 
128
- time = @timezone ? @timezone.utc_to_local(now.getutc) : now
127
+ time = time.respond_to?(:round) ? time.round : time - time.usec * 1e-6
128
+ # chop off subseconds (and yes, Ruby 1.8 doesn't have #round)
129
129
 
130
- time = time - time.usec * 1e-6 + 1
131
- # small adjustment before starting
130
+ time = time + 1
131
+ # start at the next second
132
132
 
133
133
  loop do
134
134
 
@@ -150,7 +150,7 @@ module Rufus
150
150
 
151
151
  if @timezone
152
152
  time = @timezone.local_to_utc(time)
153
- time = time.getlocal unless now.utc?
153
+ time = time.getlocal unless from.utc?
154
154
  end
155
155
 
156
156
  time
@@ -159,19 +159,19 @@ module Rufus
159
159
  # Returns the previous the cronline matched. It's like next_time, but
160
160
  # for the past.
161
161
  #
162
- def previous_time(now=Time.now)
162
+ def previous_time(from=Time.now)
163
163
 
164
164
  # looks back by slices of two hours,
165
165
  #
166
166
  # finds for '* * * * sun', '* * 13 * *' and '0 12 13 * *'
167
167
  # starting 1970, 1, 1 in 1.8 to 2 seconds (says Rspec)
168
168
 
169
- start = current = now - 2 * 3600
169
+ start = current = from - 2 * 3600
170
170
  result = nil
171
171
 
172
172
  loop do
173
173
  nex = next_time(current)
174
- return (result ? result : previous_time(start)) if nex > now
174
+ return (result ? result : previous_time(start)) if nex > from
175
175
  result = current = nex
176
176
  end
177
177
 
@@ -196,9 +196,38 @@ module Rufus
196
196
  ]
197
197
  end
198
198
 
199
- private
199
+ # Returns the shortest delta between two potential occurences of the
200
+ # schedule described by this cronline.
201
+ #
202
+ def frequency
203
+
204
+ delta = 366 * DAY_S
205
+
206
+ t0 = previous_time(Time.local(2000, 1, 1))
207
+
208
+ loop do
209
+
210
+ break if delta <= 1
211
+ break if delta <= 60 && @seconds && @seconds.size == 1
212
+
213
+ t1 = next_time(t0)
214
+ d = t1 - t0
215
+ delta = d if d < delta
216
+
217
+ break if @months == nil && t1.month == 2
218
+ break if t1.year == 2001
219
+
220
+ t0 = t1
221
+ end
222
+
223
+ delta
224
+ end
225
+
226
+ protected
200
227
 
201
228
  WEEKDAYS = %w[ sun mon tue wed thu fri sat ]
229
+ DAY_S = 24 * 3600
230
+ WEEK_S = 7 * DAY_S
202
231
 
203
232
  def parse_weekdays(item)
204
233
 
@@ -281,7 +310,7 @@ module Rufus
281
310
 
282
311
  raise ArgumentError.new(
283
312
  "#{item.inspect} is not in range #{min}..#{max}"
284
- ) if sta < min or edn > max
313
+ ) if sta < min || edn > max
285
314
 
286
315
  r = []
287
316
  val = sta
@@ -22,11 +22,63 @@
22
22
  # Made in Japan.
23
23
  #++
24
24
 
25
-
26
25
  module Rufus
27
- module Scheduler
28
26
 
29
- VERSION = '2.0.24'
30
- end
27
+ class Scheduler
28
+
29
+ #
30
+ # The array rufus-scheduler uses to keep jobs in order (next to trigger
31
+ # first).
32
+ #
33
+ class JobArray
34
+
35
+ def initialize
36
+
37
+ @mutex = Mutex.new
38
+ @array = []
39
+ end
40
+
41
+ def push(job)
42
+
43
+ @mutex.synchronize { @array << job unless @array.index(job) }
44
+
45
+ self
46
+ end
47
+
48
+ def size
49
+
50
+ @array.size
51
+ end
52
+
53
+ def each(now, &block)
54
+
55
+ to_a.sort_by { |j| j.next_time || (now + 1) }.each do |job|
56
+
57
+ break unless job.next_time
58
+ break if job.next_time > now
59
+
60
+ block.call(job)
61
+ end
62
+ end
63
+
64
+ def delete_unscheduled
65
+
66
+ @mutex.synchronize {
67
+
68
+ @array.delete_if { |j| j.next_time.nil? || j.unscheduled_at }
69
+ }
70
+ end
71
+
72
+ def to_a
73
+
74
+ @mutex.synchronize { @array.dup }
75
+ end
76
+
77
+ def [](job_id)
78
+
79
+ @mutex.synchronize { @array.find { |j| j.job_id == job_id } }
80
+ end
81
+ end
82
+ end
31
83
  end
32
84
 
@@ -0,0 +1,548 @@
1
+ #--
2
+ # Copyright (c) 2006-2013, John Mettraux, jmettraux@gmail.com
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ #
22
+ # Made in Japan.
23
+ #++
24
+
25
+
26
+ module Rufus
27
+
28
+ class Scheduler
29
+
30
+ #--
31
+ # job classes
32
+ #++
33
+
34
+ class Job
35
+
36
+ #
37
+ # Used by Job#kill
38
+ #
39
+ class KillSignal < StandardError; end
40
+
41
+ attr_reader :id
42
+ attr_reader :opts
43
+ attr_reader :original
44
+ attr_reader :scheduled_at
45
+ attr_reader :last_time
46
+ attr_reader :unscheduled_at
47
+ attr_reader :tags
48
+
49
+ # next trigger time
50
+ #
51
+ attr_accessor :next_time
52
+
53
+ # anything with a #call(job[, timet]) method,
54
+ # what gets actually triggered
55
+ #
56
+ attr_reader :callable
57
+
58
+ # a reference to the instance whose call method is the @callable
59
+ #
60
+ attr_reader :handler
61
+
62
+ def initialize(scheduler, original, opts, block)
63
+
64
+ @scheduler = scheduler
65
+ @original = original
66
+ @opts = opts
67
+
68
+ @handler = block
69
+
70
+ @callable =
71
+ if block.respond_to?(:arity)
72
+ block
73
+ elsif block.respond_to?(:call)
74
+ block.method(:call)
75
+ elsif block.is_a?(Class)
76
+ @handler = block.new
77
+ @handler.method(:call) rescue nil
78
+ else
79
+ nil
80
+ end
81
+
82
+ @scheduled_at = Time.now
83
+ @unscheduled_at = nil
84
+ @last_time = nil
85
+ #@mutexes = {}
86
+ #@pool_mutex = Mutex.new
87
+
88
+ @locals = {}
89
+ @local_mutex = Mutex.new
90
+
91
+ @id = determine_id
92
+
93
+ raise(
94
+ ArgumentError,
95
+ 'missing block or callable to schedule',
96
+ caller[2..-1]
97
+ ) unless @callable
98
+
99
+ @tags = Array(opts[:tag] || opts[:tags]).collect { |t| t.to_s }
100
+
101
+ # tidy up options
102
+
103
+ if @opts[:allow_overlap] == false || @opts[:allow_overlapping] == false
104
+ @opts[:overlap] = false
105
+ end
106
+ if m = @opts[:mutex]
107
+ @opts[:mutex] = Array(m)
108
+ end
109
+ end
110
+
111
+ alias job_id id
112
+
113
+ def trigger(time)
114
+
115
+ set_next_time(false, time)
116
+
117
+ return if opts[:overlap] == false && running?
118
+
119
+ r = callback(:pre, time)
120
+
121
+ return if r == false
122
+
123
+ if opts[:blocking]
124
+ do_trigger(time)
125
+ else
126
+ do_trigger_in_thread(time)
127
+ end
128
+ end
129
+
130
+ def unschedule
131
+
132
+ @unscheduled_at = Time.now
133
+ end
134
+
135
+ def threads
136
+
137
+ Thread.list.select { |t| t[:rufus_scheduler_job] == self }
138
+ end
139
+
140
+ # Kills all the threads this Job currently has going on.
141
+ #
142
+ def kill
143
+
144
+ threads.each { |t| t.raise(KillSignal) }
145
+ end
146
+
147
+ def running?
148
+
149
+ threads.any?
150
+ end
151
+
152
+ def scheduled?
153
+
154
+ @scheduler.scheduled?(self)
155
+ end
156
+
157
+ def []=(key, value)
158
+
159
+ @local_mutex.synchronize { @locals[key] = value }
160
+ end
161
+
162
+ def [](key)
163
+
164
+ @local_mutex.synchronize { @locals[key] }
165
+ end
166
+
167
+ def key?(key)
168
+
169
+ @local_mutex.synchronize { @locals.key?(key) }
170
+ end
171
+
172
+ def keys
173
+
174
+ @local_mutex.synchronize { @locals.keys }
175
+ end
176
+
177
+ #def hash
178
+ # self.object_id
179
+ #end
180
+ #def eql?(o)
181
+ # o.class == self.class && o.hash == self.hash
182
+ #end
183
+ #
184
+ # might be necessary at some point
185
+
186
+ protected
187
+
188
+ def callback(position, time)
189
+
190
+ name = position == :pre ? :on_pre_trigger : :on_post_trigger
191
+
192
+ return unless @scheduler.respond_to?(name)
193
+
194
+ args = @scheduler.method(name).arity < 2 ? [ self ] : [ self, time ]
195
+
196
+ @scheduler.send(name, *args)
197
+ end
198
+
199
+ def compute_timeout
200
+
201
+ if to = @opts[:timeout]
202
+ Rufus::Scheduler.parse(to)
203
+ else
204
+ nil
205
+ end
206
+ end
207
+
208
+ def mutex(m)
209
+
210
+ m.is_a?(Mutex) ? m : (@scheduler.mutexes[m.to_s] ||= Mutex.new)
211
+ end
212
+
213
+ def do_trigger(time)
214
+
215
+ t = Time.now
216
+ # if there are mutexes, t might be really bigger than time
217
+
218
+ Thread.current[:rufus_scheduler_job] = self
219
+ Thread.current[:rufus_scheduler_time] = t
220
+ Thread.current[:rufus_scheduler_timeout] = compute_timeout
221
+
222
+ @last_time = t
223
+
224
+ args = [ self, time ][0, @callable.arity]
225
+ @callable.call(*args)
226
+
227
+ rescue KillSignal
228
+
229
+ # discard
230
+
231
+ rescue StandardError => se
232
+
233
+ @scheduler.on_error(self, se)
234
+
235
+ ensure
236
+
237
+ post_trigger(time)
238
+
239
+ Thread.current[:rufus_scheduler_job] = nil
240
+ Thread.current[:rufus_scheduler_time] = nil
241
+ Thread.current[:rufus_scheduler_timeout] = nil
242
+ end
243
+
244
+ def post_trigger(time)
245
+
246
+ set_next_time(true, time)
247
+
248
+ callback(:post, time)
249
+ end
250
+
251
+ def start_work_thread
252
+
253
+ thread =
254
+ Thread.new do
255
+
256
+ Thread.current[@scheduler.thread_key] = true
257
+ Thread.current[:rufus_scheduler_job_thread] = true
258
+
259
+ loop do
260
+
261
+ job, time = @scheduler.work_queue.pop
262
+
263
+ break if @scheduler.started_at == nil
264
+
265
+ next if job.unscheduled_at
266
+
267
+ begin
268
+
269
+ (job.opts[:mutex] || []).reduce(
270
+ lambda { job.do_trigger(time) }
271
+ ) do |b, m|
272
+ lambda { mutex(m).synchronize { b.call } }
273
+ end.call
274
+
275
+ rescue KillSignal
276
+
277
+ # simply go on looping
278
+ end
279
+ end
280
+ end
281
+
282
+ thread[@scheduler.thread_key] = true
283
+ thread[:rufus_scheduler_work_thread] = true
284
+ #
285
+ # same as above (in the thead block),
286
+ # but since it has to be done as quickly as possible.
287
+ # So, whoever is running first (scheduler thread vs job thread)
288
+ # sets this information
289
+ end
290
+
291
+ def do_trigger_in_thread(time)
292
+
293
+ #@pool_mutex.synchronize do
294
+
295
+ count = @scheduler.work_threads.size
296
+ #vacant = threads.select { |t| t[:rufus_scheduler_job] == nil }.size
297
+ #min = @scheduler.min_work_threads
298
+ max = @scheduler.max_work_threads
299
+
300
+ start_work_thread if count < max
301
+ #end
302
+
303
+ @scheduler.work_queue << [ self, time ]
304
+ end
305
+ end
306
+
307
+ class OneTimeJob < Job
308
+
309
+ alias time next_time
310
+
311
+ protected
312
+
313
+ def determine_id
314
+
315
+ [
316
+ self.class.name.split(':').last.downcase[0..-4],
317
+ @scheduled_at.to_f,
318
+ @next_time.to_f,
319
+ opts.hash.abs
320
+ ].map(&:to_s).join('_')
321
+ end
322
+
323
+ # There is no next_time for one time jobs, hence the false.
324
+ #
325
+ def set_next_time(is_post, trigger_time)
326
+
327
+ @next_time = is_post ? nil : false
328
+ end
329
+ end
330
+
331
+ class AtJob < OneTimeJob
332
+
333
+ def initialize(scheduler, time, opts, block)
334
+
335
+ super(scheduler, time, opts, block)
336
+
337
+ @next_time = Rufus::Scheduler.parse_at(time)
338
+ end
339
+ end
340
+
341
+ class InJob < OneTimeJob
342
+
343
+ def initialize(scheduler, duration, opts, block)
344
+
345
+ super(scheduler, duration, opts, block)
346
+
347
+ @next_time = @scheduled_at + Rufus::Scheduler.parse_in(duration)
348
+ end
349
+ end
350
+
351
+ class RepeatJob < Job
352
+
353
+ attr_reader :paused_at
354
+
355
+ attr_reader :first_at
356
+ attr_accessor :last_at
357
+ attr_accessor :times
358
+
359
+ def initialize(scheduler, duration, opts, block)
360
+
361
+ super
362
+
363
+ @paused_at = nil
364
+
365
+ @times = opts[:times]
366
+
367
+ raise ArgumentError.new(
368
+ "cannot accept :times => #{@times.inspect}, not nil or an int"
369
+ ) unless @times == nil || @times.is_a?(Fixnum)
370
+
371
+ self.first_at =
372
+ opts[:first] || opts[:first_at] || opts[:first_in] || 0
373
+ self.last_at =
374
+ opts[:last] || opts[:last_at] || opts[:last_in]
375
+ end
376
+
377
+ def first_at=(first)
378
+
379
+ @first_at = Rufus::Scheduler.parse_to_time(first)
380
+
381
+ raise ArgumentError.new(
382
+ "cannot set first[_at|_in] in the past: " +
383
+ "#{first.inspect} -> #{@first_at.inspect}"
384
+ ) if first != 0 && @first_at < Time.now
385
+ end
386
+
387
+ def last_at=(last)
388
+
389
+ @last_at = last ? Rufus::Scheduler.parse_to_time(last) : nil
390
+
391
+ raise ArgumentError.new(
392
+ "cannot set last[_at|_in] in the past: " +
393
+ "#{last.inspect} -> #{@last_at.inspect}"
394
+ ) if last && @last_at < Time.now
395
+ end
396
+
397
+ def trigger(time)
398
+
399
+ return if @paused_at
400
+ return if time < @first_at
401
+ #
402
+ # TODO: remove me when @first_at gets reworked
403
+
404
+ return (@next_time = nil) if @times && @times < 1
405
+ return (@next_time = nil) if @last_at && time >= @last_at
406
+ #
407
+ # TODO: rework that, jobs are thus kept 1 step too much in @jobs
408
+
409
+ super
410
+
411
+ @times = @times - 1 if @times
412
+ end
413
+
414
+ def pause
415
+
416
+ @paused_at = Time.now
417
+ end
418
+
419
+ def resume
420
+
421
+ @paused_at = nil
422
+ end
423
+
424
+ def paused?
425
+
426
+ @paused_at != nil
427
+ end
428
+
429
+ def determine_id
430
+
431
+ [
432
+ self.class.name.split(':').last.downcase[0..-4],
433
+ @scheduled_at.to_f,
434
+ opts.hash.abs
435
+ ].map(&:to_s).join('_')
436
+ end
437
+ end
438
+
439
+ #
440
+ # A parent class of EveryJob and IntervalJob
441
+ #
442
+ class EvInJob < RepeatJob
443
+
444
+ def first_at=(first)
445
+
446
+ super
447
+
448
+ @next_time = @first_at
449
+ end
450
+ end
451
+
452
+ class EveryJob < EvInJob
453
+
454
+ attr_reader :frequency
455
+
456
+ def initialize(scheduler, duration, opts, block)
457
+
458
+ super(scheduler, duration, opts, block)
459
+
460
+ @frequency = Rufus::Scheduler.parse_in(@original)
461
+
462
+ raise ArgumentError.new(
463
+ "cannot schedule #{self.class} with a frequency " +
464
+ "of #{@frequency.inspect} (#{@original.inspect})"
465
+ ) if @frequency <= 0
466
+
467
+ set_next_time(false, nil)
468
+ end
469
+
470
+ protected
471
+
472
+ def set_next_time(is_post, trigger_time)
473
+
474
+ return if is_post
475
+
476
+ @next_time =
477
+ if trigger_time
478
+ trigger_time + @frequency
479
+ elsif @first_at < Time.now
480
+ Time.now + @frequency
481
+ else
482
+ @first_at
483
+ end
484
+ end
485
+ end
486
+
487
+ class IntervalJob < EvInJob
488
+
489
+ attr_reader :interval
490
+
491
+ def initialize(scheduler, interval, opts, block)
492
+
493
+ super(scheduler, interval, opts, block)
494
+
495
+ @interval = Rufus::Scheduler.parse_in(@original)
496
+
497
+ raise ArgumentError.new(
498
+ "cannot schedule #{self.class} with an interval " +
499
+ "of #{@interval.inspect} (#{@original.inspect})"
500
+ ) if @interval <= 0
501
+
502
+ set_next_time(false, nil)
503
+ end
504
+
505
+ protected
506
+
507
+ def set_next_time(is_post, trigger_time)
508
+
509
+ @next_time =
510
+ if is_post
511
+ Time.now + @interval
512
+ elsif trigger_time.nil?
513
+ if @first_at < Time.now
514
+ Time.now + @interval
515
+ else
516
+ @first_at
517
+ end
518
+ else
519
+ false
520
+ end
521
+ end
522
+ end
523
+
524
+ class CronJob < RepeatJob
525
+
526
+ def initialize(scheduler, cronline, opts, block)
527
+
528
+ super(scheduler, cronline, opts, block)
529
+
530
+ @cron_line = CronLine.new(cronline)
531
+ @next_time = @cron_line.next_time
532
+ end
533
+
534
+ def frequency
535
+
536
+ @cron_line.frequency
537
+ end
538
+
539
+ protected
540
+
541
+ def set_next_time(is_post, trigger_time)
542
+
543
+ @next_time = @cron_line.next_time
544
+ end
545
+ end
546
+ end
547
+ end
548
+