rufus-scheduler 3.0.8 → 3.1.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.
data/CHANGELOG.txt CHANGED
@@ -2,6 +2,19 @@
2
2
  = rufus-scheduler CHANGELOG.txt
3
3
 
4
4
 
5
+ == rufus-scheduler - 3.1.0 released 2015/04/18
6
+
7
+ - go without tzinfo (and its dependencies)
8
+ - include @ketan's #next_time improvements
9
+ - remove 2.x warning message on install
10
+
11
+
12
+ == rufus-scheduler - 3.0.9 released 2014/08/30
13
+
14
+ - fix TZ with underscores, thanks https://github.com/gnilrets
15
+ - integrate https://github.com/ecin Lock mecha
16
+
17
+
5
18
  == rufus-scheduler - 3.0.8 released 2014/06/09
6
19
 
7
20
  - handle TZInfo errors on DST transitions, thanks https://github.com/junhanamaki
data/CREDITS.txt CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  == Contributors
6
6
 
7
+ - Ketan Padegaonkar (https://github.com/ketan) .brute_frequency improvement
8
+ - Gabriel Gilder (https://github.com/ggilder) LA DST specs
9
+ - Sterling Paramore (https://github.com/gnilrets) underscore TZ fix
10
+ - ecin (https://github.com/ecin) new lock mecha
7
11
  - Adam Jonas (https://github.com/adamjonas) migrate specs to "expect"
8
12
  - Yassen Bantchev (https://github.com/yassenb) CronLine#previous_time rewrite
9
13
  - Eric Lindvall (https://github.com/eric) Zookeeper locked example
@@ -31,6 +35,7 @@
31
35
 
32
36
  == Feedback
33
37
 
38
+ - Michael Guymon - https://github.com/mguymon - #next_time vs :first_at
34
39
  - junhanamaki - https://github.com/junhanamaki - #next_time and dst ambiguities
35
40
  - kreynolds (tossrock) - inspiration for #occurrences
36
41
  - Matteo - https://github.com/m4ce - dst and cron issue
data/LICENSE.txt CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- Copyright (c) 2005-2014, John Mettraux, jmettraux@gmail.com
2
+ Copyright (c) 2005-2015, John Mettraux, jmettraux@gmail.com
3
3
 
4
4
  Permission is hereby granted, free of charge, to any person obtaining a copy
5
5
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -61,6 +61,7 @@ It does not persist your schedules. When the process is gone and the scheduler i
61
61
 
62
62
  * [whenever](https://github.com/javan/whenever) - let cron call back your Ruby code, trusted and reliable cron drives your schedule
63
63
  * [clockwork](https://github.com/tomykaira/clockwork) - rufus-scheduler inspired gem
64
+ * [crono](https://github.com/plashchynski/crono) - an in-Rails cron scheduler
64
65
 
65
66
  (please note: rufus-scheduler is not a cron replacement)
66
67
 
@@ -72,6 +73,11 @@ It's a complete rewrite of rufus-scheduler.
72
73
  There is no EventMachine-based scheduler anymore.
73
74
 
74
75
 
76
+ ## I don't know what this Ruby thing is, where are my Rails?
77
+
78
+ Sir, I'll drive you right to the [tracks](#so-rails).
79
+
80
+
75
81
  ## Notables changes:
76
82
 
77
83
  * As said, no more EventMachine-based scheduler
@@ -89,6 +95,8 @@ There is no EventMachine-based scheduler anymore.
89
95
 
90
96
  So you need help. People can help you, but first help them help you, and don't waste their time. Provide a complete description of the issue. If it works on A but not on B and others have to ask you: "so what is different between A and B" you are wasting everyone's time.
91
97
 
98
+ "hello" and "thanks" are not swear words.
99
+
92
100
  Go read [how to report bugs effectively](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html), twice.
93
101
 
94
102
  Update: [help_help.md](https://gist.github.com/jmettraux/310fed75f568fd731814) might help help you.
@@ -1097,6 +1105,57 @@ This is useful in environments where the Ruby process holding the scheduler gets
1097
1105
 
1098
1106
  If the lockfile mechanism here is not sufficient, you can plug your custom mechanism. It's explained in [advanced lock schemes](#advanced-lock-schemes) below.
1099
1107
 
1108
+ ### :scheduler_lock
1109
+
1110
+ (since rufus-scheduler 3.0.9)
1111
+
1112
+ The scheduler lock is an object that responds to `#lock` and `#unlock`. The scheduler calls `#lock` when starting up. If the answer is `false`, the scheduler stops its initialization work and won't schedule anything.
1113
+
1114
+ Here is a sample of a scheduler lock that only lets the scheduler on host "coffee.example.com" start:
1115
+ ```ruby
1116
+ class HostLock
1117
+ def initialize(lock_name)
1118
+ @lock_name = lock_name
1119
+ end
1120
+ def lock
1121
+ @lock_name == `hostname -f`.strip
1122
+ end
1123
+ def unlock
1124
+ true
1125
+ end
1126
+ end
1127
+
1128
+ scheduler =
1129
+ Rufus::Scheduler.new(:scheduler_lock => HostLock.new('coffee.example.com'))
1130
+ ```
1131
+
1132
+ By default, the scheduler_lock is an instance of `Rufus::Scheduler::NullLock`, with a `#lock` that returns true.
1133
+
1134
+ ### :trigger_lock
1135
+
1136
+ (since rufus-scheduler 3.0.9)
1137
+
1138
+ The trigger lock in an object that responds to `#lock`. The scheduler calls that method on the job lock right before triggering any job. If the answer is false, the trigger doesn't happen, the job is not done (at least not in this scheduler).
1139
+
1140
+ Here is a (stupid) PingLock example, it'll only trigger if an "other host" is not responding to ping. Do not use that in production, you don't want to fork a ping process for each trigger attempt...
1141
+ ```ruby
1142
+ class PingLock
1143
+ def initialize(other_host)
1144
+ @other_host = other_host
1145
+ end
1146
+ def lock
1147
+ ! system("ping -c 1 #{@other_host}")
1148
+ end
1149
+ end
1150
+
1151
+ scheduler =
1152
+ Rufus::Scheduler.new(:trigger_lock => PingLock.new('main.example.com'))
1153
+ ```
1154
+
1155
+ By default, the trigger_lock is an instance of `Rufus::Scheduler::NullLock`, with a `#lock` that always returns true.
1156
+
1157
+ As explained in [advanced lock schemes](#advanced-lock-schemes), another way to tune that behaviour is by overriding the scheduler's `#confirm_lock` method. (You could also do that with an `#on_pre_trigger` callback).
1158
+
1100
1159
  ### :max_work_threads
1101
1160
 
1102
1161
  In rufus-scheduler 2.x, by default, each job triggering received its own, brand new, thread of execution. In rufus-scheduler 3.x, execution happens in a pooled work thread. The max work thread count (the pool size) defaults to 28.
@@ -1183,6 +1242,20 @@ to make sure that the lock is still valid.
1183
1242
 
1184
1243
  The #confirm_lock method is called right before a job triggers (if it is provided). The more generic callback #on_pre_trigger is called right after #confirm_lock.
1185
1244
 
1245
+ ### :scheduler_lock and :trigger_lock
1246
+
1247
+ (introduced in rufus-scheduler 3.0.9).
1248
+
1249
+ Another way of prodiving `#lock`, `#unlock` and `#confirm_lock` to a rufus-scheduler is by using the `:scheduler_lock` and `:trigger_lock` options.
1250
+
1251
+ See [:trigger_lock](#trigger_lock) and [:scheduler_lock](#scheduler_lock).
1252
+
1253
+ The scheduler lock may be used to prevent a scheduler from starting, while a trigger lock prevents individual jobs from triggering (the scheduler goes on scheduling).
1254
+
1255
+ One has to be careful with what goes in `#confirm_lock` or in a trigger lock, as it gets called before each trigger.
1256
+
1257
+ Warning: you may think you're heading towards "high availability" by using a trigger lock and having lots of schedulers at hand. It may be so if you limit yourself to scheduling the same set of jobs at scheduler startup. But if you add schedules at runtime, they stay local to their scheduler. There is no magic that propagates the jobs to all the schedulers in your pack.
1258
+
1186
1259
 
1187
1260
  ## parsing cronlines and time strings
1188
1261
 
@@ -1303,19 +1376,6 @@ Rufus::Scheduler.parse("2013-12-12 14:00 Pacific/Saipan")
1303
1376
  # => 2013-12-12 04:00:00 UTC
1304
1377
  ```
1305
1378
 
1306
- Behind the scenes, rufus-scheduler uses [tzinfo](http://tzinfo.github.io/) to deal with timezones.
1307
-
1308
- Here is a [list of timezones](misc/tz_all.txt) known to my Debian GNU/Linux 7. It was generated with this script:
1309
-
1310
- ```ruby
1311
- require 'tzinfo'
1312
- TZInfo::Timezone.all.each { |tz| puts tz.name }
1313
- ```
1314
-
1315
- Unknown timezones, typos, will be rejected by tzinfo thus rufus-scheduler.
1316
-
1317
- On its own tzinfo derives the timezones from the system's information. On some system it needs some help, one can install the 'tzinfo-data' gem to provide the missing information.
1318
-
1319
1379
 
1320
1380
  ## so Rails?
1321
1381
 
@@ -1,5 +1,5 @@
1
1
  #--
2
- # Copyright (c) 2006-2014, John Mettraux, jmettraux@gmail.com
2
+ # Copyright (c) 2006-2015, John Mettraux, jmettraux@gmail.com
3
3
  #
4
4
  # Permission is hereby granted, free of charge, to any person obtaining a copy
5
5
  # of this software and associated documentation files (the "Software"), to deal
@@ -22,6 +22,8 @@
22
22
  # Made in Japan.
23
23
  #++
24
24
 
25
+ require 'set'
26
+
25
27
 
26
28
  class Rufus::Scheduler
27
29
 
@@ -54,8 +56,7 @@ class Rufus::Scheduler
54
56
 
55
57
  items = line.split
56
58
 
57
- @timezone = (TZInfo::Timezone.get(items.last) rescue nil)
58
- items.pop if @timezone
59
+ @timezone = items.pop if ZoTime.is_timezone?(items.last)
59
60
 
60
61
  raise ArgumentError.new(
61
62
  "not a valid cronline : '#{line}'"
@@ -82,9 +83,7 @@ class Rufus::Scheduler
82
83
  #
83
84
  def matches?(time)
84
85
 
85
- time = Time.at(time) unless time.kind_of?(Time)
86
-
87
- time = @timezone.utc_to_local(time.getutc) if @timezone
86
+ time = ZoTime.new(time.to_f, @timezone || ENV['TZ']).time
88
87
 
89
88
  return false unless sub_match?(time, :sec, @seconds)
90
89
  return false unless sub_match?(time, :min, @minutes)
@@ -122,37 +121,34 @@ class Rufus::Scheduler
122
121
  #
123
122
  def next_time(from=Time.now)
124
123
 
125
- time = local_time(from)
126
- time = round_to_seconds(time)
127
-
128
- # start at the next second
129
- time = time + 1
124
+ time = nil
125
+ zotime = ZoTime.new(from.to_i + 1, @timezone || ENV['TZ'])
130
126
 
131
127
  loop do
128
+
129
+ time = zotime.time
130
+
132
131
  unless date_match?(time)
133
- dst = time.isdst
134
- time += (24 - time.hour) * 3600 - time.min * 60 - time.sec
135
- time -= 3600 if time.isdst != dst # not necessary for winter, but...
132
+ zotime.add((24 - time.hour) * 3600 - time.min * 60 - time.sec)
136
133
  next
137
134
  end
138
135
  unless sub_match?(time, :hour, @hours)
139
- time += (60 - time.min) * 60 - time.sec; next
136
+ zotime.add((60 - time.min) * 60 - time.sec)
137
+ next
140
138
  end
141
139
  unless sub_match?(time, :min, @minutes)
142
- time += 60 - time.sec; next
140
+ zotime.add(60 - time.sec)
141
+ next
143
142
  end
144
143
  unless sub_match?(time, :sec, @seconds)
145
- time += 1; next
144
+ zotime.add(next_second(time))
145
+ next
146
146
  end
147
147
 
148
148
  break
149
149
  end
150
150
 
151
- global_time(time, from.utc?)
152
-
153
- rescue TZInfo::PeriodNotFound
154
-
155
- next_time(from + 3600)
151
+ time
156
152
  end
157
153
 
158
154
  # Returns the previous time the cronline matched. It's like next_time, but
@@ -160,35 +156,48 @@ class Rufus::Scheduler
160
156
  #
161
157
  def previous_time(from=Time.now)
162
158
 
163
- time = local_time(from)
164
- time = round_to_seconds(time)
165
-
166
- # start at the previous second
167
- time = time - 1
159
+ time = nil
160
+ zotime = ZoTime.new(from.to_i - 1, @timezone || ENV['TZ'])
168
161
 
169
162
  loop do
163
+
164
+ time = zotime.time
165
+
170
166
  unless date_match?(time)
171
- time -= time.hour * 3600 + time.min * 60 + time.sec + 1; next
167
+ zotime.substract(time.hour * 3600 + time.min * 60 + time.sec + 1)
168
+ next
172
169
  end
173
170
  unless sub_match?(time, :hour, @hours)
174
- time -= time.min * 60 + time.sec + 1; next
171
+ zotime.substract(time.min * 60 + time.sec + 1)
172
+ next
175
173
  end
176
174
  unless sub_match?(time, :min, @minutes)
177
- time -= time.sec + 1; next
175
+ zotime.substract(time.sec + 1)
176
+ next
178
177
  end
179
178
  unless sub_match?(time, :sec, @seconds)
180
- time -= 1; next
179
+ zotime.substract(prev_second(time))
180
+ next
181
181
  end
182
182
 
183
183
  break
184
184
  end
185
185
 
186
- global_time(time, from.utc?)
187
-
188
- rescue TZInfo::PeriodNotFound
186
+ time
187
+ end
189
188
 
190
- previous_time(time)
189
+ if RUBY_VERSION >= '1.9'
190
+ def toa(item)
191
+ item == nil ? nil : item.to_a
192
+ end
193
+ else
194
+ def toi(item); item.is_a?(String) ? item.hash.abs : item.to_i; end
195
+ protected :toi
196
+ def toa(item)
197
+ item.is_a?(Set) ? item.to_a.sort_by { |e| toi(e) } : item
198
+ end
191
199
  end
200
+ protected :toa
192
201
 
193
202
  # Returns an array of 6 arrays (seconds, minutes, hours, days,
194
203
  # months, weekdays).
@@ -197,14 +206,14 @@ class Rufus::Scheduler
197
206
  def to_array
198
207
 
199
208
  [
200
- @seconds,
201
- @minutes,
202
- @hours,
203
- @days,
204
- @months,
205
- @weekdays,
206
- @monthdays,
207
- @timezone ? @timezone.name : nil
209
+ toa(@seconds),
210
+ toa(@minutes),
211
+ toa(@hours),
212
+ toa(@days),
213
+ toa(@months),
214
+ toa(@weekdays),
215
+ toa(@monthdays),
216
+ @timezone
208
217
  ]
209
218
  end
210
219
 
@@ -212,7 +221,7 @@ class Rufus::Scheduler
212
221
  # cron line.
213
222
  #
214
223
  # #brute_frequency, on the other hand, will compute the frequency by
215
- # examining a whole, that can take more than seconds for a seconds
224
+ # examining a whole year, that can take more than seconds for a seconds
216
225
  # level cron...
217
226
  #
218
227
  def frequency
@@ -220,9 +229,10 @@ class Rufus::Scheduler
220
229
  return brute_frequency unless @seconds && @seconds.length > 1
221
230
 
222
231
  delta = 60
223
- prev = @seconds[0]
232
+ secs = toa(@seconds)
233
+ prev = secs[0]
224
234
 
225
- @seconds[1..-1].each do |sec|
235
+ secs[1..-1].each do |sec|
226
236
  d = sec - prev
227
237
  delta = d if d < delta
228
238
  end
@@ -270,7 +280,7 @@ class Rufus::Scheduler
270
280
  delta = d if d < delta
271
281
 
272
282
  break if @months == nil && t1.month == 2
273
- break if t1.year == 2001
283
+ break if t1.year >= 2001
274
284
 
275
285
  t0 = t1
276
286
  end
@@ -280,6 +290,26 @@ class Rufus::Scheduler
280
290
 
281
291
  protected
282
292
 
293
+ def next_second(time)
294
+
295
+ secs = @seconds.sort
296
+
297
+ return secs.last + 60 - time.sec if time.sec > secs.last
298
+
299
+ secs.shift while secs.first < time.sec
300
+
301
+ secs.first - time.sec
302
+ end
303
+
304
+ def prev_second(time)
305
+
306
+ secs = @seconds.sort
307
+
308
+ secs.pop while time.sec < secs.last
309
+
310
+ time.sec - secs.last
311
+ end
312
+
283
313
  WEEKDAYS = %w[ sun mon tue wed thu fri sat ]
284
314
  DAY_S = 24 * 3600
285
315
  WEEK_S = 7 * DAY_S
@@ -336,7 +366,7 @@ class Rufus::Scheduler
336
366
  "found duplicates in #{item.inspect}"
337
367
  ) if r.uniq.size < r.size
338
368
 
339
- r
369
+ Set.new(r)
340
370
  end
341
371
 
342
372
  RANGE_REGEX = /^(\*|\d{1,2})(?:-(\d{1,2}))?(?:\/(\d{1,2}))?$/
@@ -435,32 +465,6 @@ class Rufus::Scheduler
435
465
 
436
466
  [ "#{WEEKDAYS[date.wday]}##{pos}", "#{WEEKDAYS[date.wday]}##{neg}" ]
437
467
  end
438
-
439
- def local_time(time)
440
-
441
- @timezone ? @timezone.utc_to_local(time.getutc) : time
442
- end
443
-
444
- def global_time(time, from_in_utc)
445
-
446
- if @timezone
447
- time =
448
- begin
449
- @timezone.local_to_utc(time)
450
- rescue TZInfo::AmbiguousTime
451
- @timezone.local_to_utc(time, time.isdst)
452
- end
453
- time = time.getlocal unless from_in_utc
454
- end
455
-
456
- time
457
- end
458
-
459
- def round_to_seconds(time)
460
-
461
- # Ruby 1.8 doesn't have #round
462
- time.respond_to?(:round) ? time.round : time - time.usec * 1e-6
463
- end
464
468
  end
465
469
  end
466
470
 
@@ -1,5 +1,5 @@
1
1
  #--
2
- # Copyright (c) 2006-2014, John Mettraux, jmettraux@gmail.com
2
+ # Copyright (c) 2006-2015, John Mettraux, jmettraux@gmail.com
3
3
  #
4
4
  # Permission is hereby granted, free of charge, to any person obtaining a copy
5
5
  # of this software and associated documentation files (the "Software"), to deal
@@ -22,6 +22,7 @@
22
22
  # Made in Japan.
23
23
  #++
24
24
 
25
+
25
26
  module Rufus
26
27
 
27
28
  class Scheduler
@@ -1,5 +1,5 @@
1
1
  #--
2
- # Copyright (c) 2006-2014, John Mettraux, jmettraux@gmail.com
2
+ # Copyright (c) 2006-2015, John Mettraux, jmettraux@gmail.com
3
3
  #
4
4
  # Permission is hereby granted, free of charge, to any person obtaining a copy
5
5
  # of this software and associated documentation files (the "Software"), to deal
@@ -117,15 +117,16 @@ module Rufus
117
117
 
118
118
  def trigger(time)
119
119
 
120
- set_next_time(false, time)
120
+ set_next_time(time)
121
121
 
122
- return if opts[:overlap] == false && running?
123
-
124
- r =
122
+ return if (
123
+ opts[:overlap] == false &&
124
+ running?
125
+ )
126
+ return if (
125
127
  callback(:confirm_lock, time) &&
126
128
  callback(:on_pre_trigger, time)
127
-
128
- return if r == false
129
+ ) == false
129
130
 
130
131
  @count += 1
131
132
 
@@ -272,7 +273,7 @@ module Rufus
272
273
 
273
274
  def post_trigger(time)
274
275
 
275
- set_next_time(true, time)
276
+ set_next_time(time, true)
276
277
 
277
278
  callback(:on_post_trigger, time)
278
279
  end
@@ -339,7 +340,7 @@ module Rufus
339
340
 
340
341
  def occurrences(time0, time1)
341
342
 
342
- time >= time0 && time <= time1 ? [ time ] : []
343
+ (time >= time0 && time <= time1) ? [ time ] : []
343
344
  end
344
345
 
345
346
  protected
@@ -356,7 +357,7 @@ module Rufus
356
357
 
357
358
  # There is no next_time for one time jobs, hence the false.
358
359
  #
359
- def set_next_time(is_post, trigger_time)
360
+ def set_next_time(trigger_time, is_post=false)
360
361
 
361
362
  @next_time = is_post ? nil : false
362
363
  end
@@ -408,15 +409,17 @@ module Rufus
408
409
  self.first_at =
409
410
  opts[:first] || opts[:first_time] ||
410
411
  opts[:first_at] || opts[:first_in] ||
411
- 0
412
+ nil
412
413
  self.last_at =
413
414
  opts[:last] || opts[:last_at] || opts[:last_in]
414
415
  end
415
416
 
416
417
  def first_at=(first)
417
418
 
419
+ return @first_at = nil if first == nil
420
+
418
421
  n = Time.now
419
- first = n + 0.001 if first == :now || first == :immediately
422
+ first = n + 0.003 if first == :now || first == :immediately
420
423
 
421
424
  @first_at = Rufus::Scheduler.parse_to_time(first)
422
425
 
@@ -439,9 +442,6 @@ module Rufus
439
442
  def trigger(time)
440
443
 
441
444
  return if @paused_at
442
- return if time < @first_at
443
- #
444
- # TODO: remove me when @first_at gets reworked
445
445
 
446
446
  return (@next_time = nil) if @times && @times < 1
447
447
  return (@next_time = nil) if @last_at && time >= @last_at
@@ -527,20 +527,18 @@ module Rufus
527
527
  "of #{@frequency.inspect} (#{@original.inspect})"
528
528
  ) if @frequency <= 0
529
529
 
530
- set_next_time(false, nil)
530
+ set_next_time(nil)
531
531
  end
532
532
 
533
533
  protected
534
534
 
535
- def set_next_time(is_post, trigger_time)
535
+ def set_next_time(trigger_time, is_post=false)
536
536
 
537
537
  return if is_post
538
538
 
539
539
  @next_time =
540
- if trigger_time
541
- trigger_time + @frequency
542
- elsif @first_at < Time.now
543
- Time.now + @frequency
540
+ if @first_at == nil || @first_at < Time.now
541
+ (trigger_time || Time.now) + @frequency
544
542
  else
545
543
  @first_at
546
544
  end
@@ -567,18 +565,18 @@ module Rufus
567
565
  "of #{@interval.inspect} (#{@original.inspect})"
568
566
  ) if @interval <= 0
569
567
 
570
- set_next_time(false, nil)
568
+ set_next_time(nil)
571
569
  end
572
570
 
573
571
  protected
574
572
 
575
- def set_next_time(is_post, trigger_time)
573
+ def set_next_time(trigger_time, is_post=false)
576
574
 
577
575
  @next_time =
578
576
  if is_post
579
577
  Time.now + @interval
580
578
  elsif trigger_time.nil?
581
- if @first_at < Time.now
579
+ if @first_at == nil || @first_at < Time.now
582
580
  Time.now + @interval
583
581
  else
584
582
  @first_at
@@ -601,7 +599,7 @@ module Rufus
601
599
  super(scheduler, cronline, opts, block)
602
600
 
603
601
  @cron_line = opts[:_t] || CronLine.new(cronline)
604
- @next_time = @cron_line.next_time
602
+ set_next_time(nil)
605
603
  end
606
604
 
607
605
  def frequency
@@ -616,14 +614,18 @@ module Rufus
616
614
 
617
615
  protected
618
616
 
619
- def set_next_time(is_post, trigger_time)
617
+ def set_next_time(trigger_time, is_post=false)
620
618
 
621
- @next_time = @cron_line.next_time
619
+ @next_time = next_time_from(trigger_time || Time.now)
622
620
  end
623
621
 
624
622
  def next_time_from(time)
625
623
 
626
- @cron_line.next_time(time)
624
+ if @first_at == nil || @first_at < time
625
+ @cron_line.next_time(time)
626
+ else
627
+ @first_at
628
+ end
627
629
  end
628
630
  end
629
631
  end