cronwatch 0.13.1 → 0.14.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.
@@ -103,12 +103,29 @@ module Cronwatch
103
103
  end
104
104
 
105
105
  # The first fire strictly after `from`, or nil when the cron never fires
106
- # again. The walker works on the wall clock and Zone.to_utc takes the
107
- # earlier of a repeated time, so, like croner, asked from inside the hour
108
- # that repeats when clocks go back it can answer with times in the past.
109
- # Its answers are filtered, and a stretch of nothing but past times is
110
- # stepped over an hour at a time. Ported from fireAfter in schedule.ts.
106
+ # again. On the night clocks spring forward every slot the pattern names
107
+ # still fires: one whose local time the jump skips is moved forward by the
108
+ # gap's length (see spring_gap and gap_fire_after), so the fires stay
109
+ # strictly ascending whichever instant they are counted from. Ported from
110
+ # fireAfter in schedule.ts.
111
111
  def fire_after(parsed, from)
112
+ found = crone_fire_after(parsed, from)
113
+ start = count_from(from)
114
+ return found if start.nil?
115
+
116
+ moved = gap_fire_after(parsed, start)
117
+ !moved.nil? && (found.nil? || moved < found) ? moved : found
118
+ end
119
+
120
+ # The first fire strictly after `from` as croner counts it, or nil when
121
+ # the cron never fires again. The walker works on the wall clock and
122
+ # Zone.to_utc takes the earlier of a repeated time, so, like croner, asked
123
+ # from inside the hour that repeats when clocks go back it can answer with
124
+ # times in the past. Its answers are filtered, and a stretch of nothing
125
+ # but past times is stepped over an hour at a time. Asked from inside a
126
+ # spring-forward gap it skips the slots moved out of it, which fire_after
127
+ # adds back. Ported from croneFireAfter in schedule.ts.
128
+ def crone_fire_after(parsed, from)
112
129
  raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
113
130
  return nil if parsed.never
114
131
 
@@ -128,10 +145,79 @@ module Cronwatch
128
145
  nil
129
146
  end
130
147
 
148
+ # A spring-forward jump: `jump_at`, the first instant on the later offset;
149
+ # `gap_ms`, the later offset less the earlier one; `before_ms`, the earlier
150
+ # offset. All in milliseconds.
151
+ Gap = Struct.new(:jump_at, :gap_ms, :before_ms)
152
+
153
+ # How far back a spring-forward jump is looked for: more than any gap a zone has had.
154
+ JUMP_LOOKBACK_MS = 3 * 3_600_000
155
+
156
+ # The window a spring-forward jump moves skipped slots into, when `at`
157
+ # lies in one: from the jump for the gap's length (30 minutes on Lord Howe
158
+ # Island, an hour in most zones). Nil when `at` is not within a gap's
159
+ # length after a jump in the three hours before it.
160
+ def spring_gap(timezone, at)
161
+ jump = spring_jump(timezone, at)
162
+ jump && at < jump.jump_at + jump.gap_ms ? jump : nil
163
+ end
164
+
165
+ # The spring-forward jump in the three hours up to `at`, found to the
166
+ # minute: the first minute on the later offset. Nil when the offset at
167
+ # `at` is not ahead of the offset three hours before.
168
+ def spring_jump(timezone, at)
169
+ # Every zone kept its local mean time, with no clock change, in the year 1.
170
+ return nil if at - JUMP_LOOKBACK_MS < Duration::FIRST_DATE_MS || at >= Duration::LAST_DATE_MS
171
+
172
+ after = utc_offset(at, timezone)
173
+ before = utc_offset(at - JUMP_LOOKBACK_MS, timezone)
174
+ return nil if after <= before
175
+
176
+ lo = at - JUMP_LOOKBACK_MS
177
+ hi = at
178
+ while hi - lo > 60_000
179
+ mid = lo + (hi - lo).div(2)
180
+ if utc_offset(mid, timezone) == after then hi = mid
181
+ else lo = mid
182
+ end
183
+ end
184
+ Gap.new(hi.div(60_000) * 60_000, after - before, before)
185
+ end
186
+
187
+ # The first slot the pattern names strictly after `at`, the pattern read
188
+ # in UTC, which stands for wall-clock time in gap_fire_after.
189
+ def wall_fire_after(parsed, at)
190
+ next_runs(parsed, 1, at, "UTC").first
191
+ end
192
+
193
+ # The first slot moved out of a spring-forward gap strictly after
194
+ # `start`, when `start` lies in that gap's window, else nil. A slot whose
195
+ # local time the jump skips fires at that local time read on the earlier
196
+ # offset, which is the same as moving it forward by the gap's length:
197
+ # 01:20 on the night London skips 01:00 to 02:00 fires at 02:20 BST
198
+ # (01:20Z). The walker moves them the same way when asked from before the
199
+ # jump, as croner does, but not from inside it.
200
+ def gap_fire_after(parsed, start)
201
+ return nil if parsed.never
202
+
203
+ gap = spring_gap(parsed.timezone, start)
204
+ return nil unless gap
205
+
206
+ # Wall-clock time as if it were UTC: the slots the jump skips run from
207
+ # jump_at + before_ms for gap_ms.
208
+ slot = wall_fire_after(parsed, start + gap.before_ms)
209
+ return nil if slot.nil? || slot >= gap.jump_at + gap.before_ms + gap.gap_ms
210
+
211
+ slot - gap.before_ms
212
+ end
213
+
131
214
  # Every fire of a cron strictly after `from` and at or before `to`,
132
- # ascending, or nil when there are more than `limit`. Walks the fires in
133
- # batches, as firesBetween in schedule.ts asks croner for them, and drops
134
- # any that do not move forward (see fire_after).
215
+ # ascending, or nil when there are more than `limit`: the fires next_fire
216
+ # gives one after another. Walks the fires in batches, as firesBetween in
217
+ # schedule.ts asks croner for them, and drops any that do not move forward
218
+ # (the hour that repeats when clocks go back, see crone_fire_after).
219
+ # Around a spring-forward gap, where a batch can skip a moved slot or give
220
+ # one out of order, it counts with fire_after instead.
135
221
  def fires_between(parsed, from, to, limit)
136
222
  raise ArgumentError, "schedule \"#{parsed.source}\" was not made by Schedule.parse" unless parsed.pattern
137
223
  return [] if parsed.never
@@ -143,17 +229,46 @@ module Cronwatch
143
229
  probe = start
144
230
  last = start
145
231
  1000.times do
232
+ if spring_gap(parsed.timezone, last)
233
+ # Counting from inside a gap's window: the walker would skip its moved slots.
234
+ t = fire_after(parsed, last)
235
+ return out if t.nil? || t > to
236
+
237
+ out << t
238
+ last = probe = t
239
+ return nil if out.length > limit
240
+
241
+ next
242
+ end
146
243
  batch = next_runs(parsed, [limit + 1 - out.length, 24].min, probe)
147
244
  return out if batch.empty?
148
245
 
246
+ stepped = false
149
247
  batch.each do |t|
150
248
  next if t <= last
249
+
250
+ if spring_gap(parsed.timezone, t)
251
+ # The batch reached a gap's window, where its order is the
252
+ # walker's: the next fire is counted from the last one, and the
253
+ # rest with it.
254
+ following = fire_after(parsed, last)
255
+ return out if following.nil? || following > to
256
+
257
+ out << following
258
+ last = probe = following
259
+ return nil if out.length > limit
260
+
261
+ stepped = true
262
+ break
263
+ end
151
264
  return out if t > to
152
265
 
153
266
  out << t
154
267
  last = t
155
268
  return nil if out.length > limit
156
269
  end
270
+ next if stepped
271
+
157
272
  finish = batch.last
158
273
  probe = finish > probe ? finish : probe + 3_600_000
159
274
  end
@@ -177,7 +292,7 @@ module Cronwatch
177
292
  # moved back, as runsAfter in schedule.ts asks croner: a time before 400
178
293
  # goes forward, into the same local mean time every zone kept then, and
179
294
  # one from 2800 goes back, to where the zone's present rules already hold.
180
- def next_runs(parsed, count, from)
295
+ def next_runs(parsed, count, from, timezone = parsed.timezone)
181
296
  from = count_from(from)
182
297
  return [] if from.nil?
183
298
 
@@ -189,7 +304,7 @@ module Cronwatch
189
304
  runs = []
190
305
  at = from + shift
191
306
  count.times do
192
- at = parsed.pattern.next_after(at, parsed.timezone)
307
+ at = parsed.pattern.next_after(at, timezone)
193
308
  break if at.nil? || at - shift > Duration::LAST_DATE_MS
194
309
 
195
310
  runs << (at - shift)
@@ -197,25 +312,38 @@ module Cronwatch
197
312
  runs
198
313
  end
199
314
 
200
- # The next time the schedule fires strictly after `from`. For an interval, counted from the last run when there is one.
315
+ # The next time the schedule fires strictly after `from`. For an interval,
316
+ # counted from the last run's start when there is one (see interval_due
317
+ # for a run that outlasted its period).
201
318
  def next_fire(parsed, from, last_run_at)
202
319
  return (last_run_at || from) + parsed.every_ms if parsed.interval?
203
320
 
204
321
  fire_after(parsed, from)
205
322
  end
206
323
 
324
+ # When an interval of `every_ms` is next due after a run: a period after
325
+ # it started, or, when it finished (`finished_at` not nil) later than
326
+ # that, a period after it finished. A run that outlasts its period is not
327
+ # followed by a missed alert the moment it ends.
328
+ def interval_due(every_ms, started_at, finished_at)
329
+ due = started_at + every_ms
330
+ !finished_at.nil? && finished_at > due ? finished_at + every_ms : due
331
+ end
332
+
207
333
  # When the schedule next wants a run, given the last one. For a cron that is
208
334
  # the first fire the last run does not already cover; with no run yet, the
209
335
  # first fire at or after registration. For an interval it is the last run's
210
- # start (or registration) plus the interval. Nil for a cron that never
211
- # fires again.
336
+ # start (or registration) plus the interval, or its finish plus the
337
+ # interval when it finished (`last_finished_at`, nil while it runs) later
338
+ # than that (interval_due). Nil for a cron that never fires again.
212
339
  #
213
340
  # Counting forward from the last run, rather than back from now, is what
214
341
  # lets a job whose period is shorter than its grace be missed at all, and it
215
342
  # works for a cron that fires once a year or less.
216
- def expectation(parsed, last_run_at, registered_at, grace_ms)
343
+ def expectation(parsed, last_run_at, registered_at, grace_ms, last_finished_at = nil)
217
344
  due_at =
218
- if parsed.interval? then (last_run_at || registered_at) + parsed.every_ms
345
+ if parsed.interval?
346
+ last_run_at.nil? ? registered_at + parsed.every_ms : interval_due(parsed.every_ms, last_run_at, last_finished_at)
219
347
  elsif last_run_at.nil? then fire_after(parsed, registered_at - 1)
220
348
  else due_after_run(parsed, last_run_at)
221
349
  end
@@ -227,12 +355,17 @@ module Cronwatch
227
355
  # year 1 is due; after 9999 there is none (see count_from).
228
356
  def due_after_run(parsed, started_at)
229
357
  # A fire at or before the start is covered by the run itself.
230
- following_fire = fire_after(parsed, started_at)
231
- return nil if following_fire.nil?
358
+ due = fire_after(parsed, started_at)
359
+ # Bounded: a gap holds at most a few hours of moved slots.
360
+ 100_000.times do
361
+ return nil if due.nil?
362
+
363
+ following = fire_after(parsed, due)
364
+ return due unless run_covers?(started_at, due, following) || in_spring_forward_gap?(parsed, started_at, due)
232
365
 
233
- following = fire_after(parsed, following_fire)
234
- covers = run_covers?(started_at, following_fire, following) || in_spring_forward_gap?(parsed, started_at, following_fire)
235
- covers ? following : following_fire
366
+ due = following
367
+ end
368
+ due
236
369
  end
237
370
 
238
371
  # Whether a run starting at `started_at` covers the fire at `due_at`. A minute
@@ -244,39 +377,21 @@ module Cronwatch
244
377
  started_at >= due_at - slack
245
378
  end
246
379
 
247
- # On the night clocks spring forward, a fire whose local time does not exist
248
- # (02:30 when 02:00 jumps to 03:00) is moved by croner to the same distance
249
- # past the jump (03:30), while vixie cron runs it at the jump itself (03:00).
250
- # A run that starts at or after the jump, and before the first fire after
251
- # it when that fire lies within one gap of it, is taken to cover that fire,
252
- # so neither scheduler's run is reported as missed. A cron that really fires
253
- # at 03:30 that night is treated the same way, which only matters if it also
254
- # ran early by up to an hour.
380
+ # On the night clocks spring forward, a slot whose local time does not
381
+ # exist (02:30 when 02:00 jumps to 03:00) fires moved forward by the gap
382
+ # (03:30; see gap_fire_after), while vixie cron runs every skipped job
383
+ # once, at the jump itself (03:00). A run that starts at or after the jump
384
+ # (less a minute of slack), and before such a moved fire, is taken to
385
+ # cover it, so neither scheduler's run is reported as missed. Only a moved
386
+ # slot that is not also a slot of its own on the later offset counts: a
387
+ # cron that fires every 10 minutes moves 02:10 onto its own 03:10, which
388
+ # was not skipped.
255
389
  def in_spring_forward_gap?(parsed, started_at, fire_at)
256
- lookback = 3 * 3_600_000
257
- # Every zone kept its local mean time, with no clock change, in the year 1.
258
- return false if fire_at - lookback < Duration::FIRST_DATE_MS
259
-
260
- after = utc_offset(fire_at, parsed.timezone)
261
- before = utc_offset(fire_at - lookback, parsed.timezone)
262
- gap = after - before
263
- return false if gap <= 0
264
-
265
- # Find the jump: the first minute in the window with the later offset.
266
- lo = fire_at - lookback
267
- hi = fire_at
268
- while hi - lo > 60_000
269
- mid = lo + (hi - lo).div(2)
270
- if utc_offset(mid, parsed.timezone) == after then hi = mid
271
- else lo = mid
272
- end
273
- end
274
- jump_at = hi.div(60_000) * 60_000
275
- return false if fire_at - jump_at >= gap || started_at < jump_at - EARLY_SLACK_MS || started_at >= fire_at
390
+ gap = spring_gap(parsed.timezone, fire_at)
391
+ return false if gap.nil? || started_at < gap.jump_at - EARLY_SLACK_MS || started_at >= fire_at
276
392
 
277
- # Only the first fire after the jump can be a moved one; a cron that also
278
- # fires at the jump (every 10 minutes, say) was not moved at all.
279
- fire_after(parsed, jump_at - 1) == fire_at
393
+ names = ->(at) { wall_fire_after(parsed, at - 1) == at }
394
+ names.call(fire_at + gap.before_ms) && !names.call(fire_at + gap.before_ms + gap.gap_ms)
280
395
  end
281
396
 
282
397
  # Milliseconds the zone's wall clock is ahead of UTC at `at`.
@@ -111,8 +111,8 @@ module Cronwatch
111
111
  lines << "Job definition: #{JS.json(definition.respond_to?(:to_h) ? definition.to_h : definition)}"
112
112
  if run
113
113
  lines << ""
114
- lines << "Triggering run: status #{run.status}, started #{stamp(run.started_at)}, duration #{duration(run)}, trigger #{run.trigger}"
115
- lines << "Metrics: #{JS.json(run.metrics)}" if run.metrics && !run.metrics.empty?
114
+ lines << "Triggering run: status #{run.status}, started #{stamp(run.started_at)}, duration #{duration(run)}, trigger #{data(run.trigger.to_s)}"
115
+ lines << "Metrics: #{data(JS.json(run.metrics))}" if run.metrics && !run.metrics.empty?
116
116
  lines << "Error:\n#{data(JS.head16(run.error, 3000))}" if present?(run.error)
117
117
  lines << "Output (tail):\n#{data(JS.tail16(run.output, 3000))}" if present?(run.output)
118
118
  end
@@ -122,7 +122,7 @@ module Cronwatch
122
122
  lines << "Earlier runs, newest first:"
123
123
  earlier.each do |r|
124
124
  error = present?(r.error) ? ", error: #{data(JS.head16(r.error.split("\n", -1).first.to_s, 160))}" : ""
125
- metrics = r.metrics && !r.metrics.empty? ? ", metrics #{JS.json(r.metrics)}" : ""
125
+ metrics = r.metrics && !r.metrics.empty? ? ", metrics #{data(JS.json(r.metrics))}" : ""
126
126
  lines << "- #{r.status}, #{stamp(r.started_at)}, #{duration(r)}#{error}#{metrics}"
127
127
  end
128
128
  end
@@ -268,6 +268,27 @@ module Cronwatch
268
268
  @triage_tried = true
269
269
  end
270
270
 
271
+ # The keys of the channels that still owe a held alert (the SDK's
272
+ # HeldAlert.owed; see Evaluate.channel_keys), as stored, or nil when it
273
+ # has none, so every configured channel owes it. Kept with the fields
274
+ # this version does not otherwise read, so it is written after them, as
275
+ # the SDK's spread of an alert writes it.
276
+ def owed = @extra&.fetch("owed", nil)
277
+
278
+ # Whether the alert carries `owed` at all, whatever it holds.
279
+ def owed? = @extra&.key?("owed") || false
280
+
281
+ # A copy with `owed` set to `keys`, or without it when `keys` is nil.
282
+ def with_owed(keys)
283
+ extra = (@extra || {}).dup
284
+ if keys.nil? then extra.delete("owed")
285
+ else extra["owed"] = keys
286
+ end
287
+ copy = dup
288
+ copy.instance_variable_set(:@extra, extra.empty? ? nil : extra)
289
+ copy
290
+ end
291
+
271
292
  # Read leniently, since a queued alert a foreign writer left malformed
272
293
  # must affect only its own job: a field of the wrong type is kept as it
273
294
  # came (a `type` or `run` that is not what it should be, `details` that
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.13.1"
5
+ VERSION = "0.14.1"
6
6
  end
@@ -209,6 +209,9 @@ module Cronwatch
209
209
  begin
210
210
  request = Request.new(env)
211
211
  path = strip_base(request.pathname, base)
212
+ # Not under the base at all: nothing here answers it, signed in or not.
213
+ return html(HTML.message_page("Not found", request.pathname, base), 404) if path.nil?
214
+
212
215
  wants_html = !path.start_with?("/api")
213
216
  serve(request, path, wants_html, base)
214
217
  rescue StandardError => e
@@ -261,6 +264,7 @@ module Cronwatch
261
264
  # the URL and every access log. Cross-site posts were refused above.
262
265
  if @token && method == "POST" && path == "/signin"
263
266
  body = read_body(request)
267
+ return html(HTML.message_page("Bad request", "Malformed request body", base), 400) if body.equal?(BAD_BODY)
264
268
  # A body too large is no token at all.
265
269
  sent = body.equal?(TOO_LARGE) ? nil : body["token"]
266
270
  return sign_in_page(base) if sent.nil? || !HTTP.constant_time_equal?(sent, @token)
@@ -338,6 +342,7 @@ module Cronwatch
338
342
  if action == "silence"
339
343
  body = read_body(request)
340
344
  return html(HTML.message_page("Not silenced", "The request was too large.", base), 413) if body.equal?(TOO_LARGE)
345
+ return html(HTML.message_page("Not silenced", "Malformed request body", base), 400) if body.equal?(BAD_BODY)
341
346
 
342
347
  begin
343
348
  duration = silence_duration(body["for"])
@@ -385,6 +390,7 @@ module Cronwatch
385
390
  if rest[2] == "silence"
386
391
  body = read_body(request)
387
392
  return api({ ok: false, error: "Request body too large" }, 413) if body.equal?(TOO_LARGE)
393
+ return api({ ok: false, error: "Malformed request body" }, 400) if body.equal?(BAD_BODY)
388
394
 
389
395
  begin
390
396
  duration = silence_duration(body.key?("for") ? body["for"] : request.query("for"))
@@ -481,8 +487,15 @@ module Cronwatch
481
487
  Digest::SHA256.hexdigest("cronwatch-cookie:#{token}")
482
488
  end
483
489
 
490
+ # The path below the base, or nil when the request is not under it. The
491
+ # pathname, its dot segments already resolved (Request#pathname), must be
492
+ # the base or start with the base and a "/": "/cronwatchapi/jobs" is not
493
+ # under "/cronwatch", and neither is "/cronwatch/../api/jobs" (read as
494
+ # "/api/jobs"). An empty base, for an app mounted at the root, takes every path.
484
495
  def strip_base(pathname, base)
485
- path = pathname.start_with?(base) ? pathname[base.length..] : pathname
496
+ return nil unless base.empty? || pathname == base || pathname.start_with?("#{base}/")
497
+
498
+ path = pathname[base.length..]
486
499
  path = "/" if path.empty?
487
500
  path = path[0...-1] if path.length > 1 && path.end_with?("/")
488
501
  path
@@ -520,7 +533,8 @@ module Cronwatch
520
533
 
521
534
  # A request body past Request::MAX_BODY, which read_body answers with.
522
535
  TOO_LARGE = Object.new.freeze
523
- private_constant :TOO_LARGE
536
+ BAD_BODY = Object.new.freeze
537
+ private_constant :TOO_LARGE, :BAD_BODY
524
538
 
525
539
  # The form fields or JSON object of a request, each value as String(value)
526
540
  # gives it in JavaScript, or TOO_LARGE for a body past Request::MAX_BODY.
@@ -536,13 +550,13 @@ module Cronwatch
536
550
  if json
537
551
  data = JSON.parse(Output.utf8(request.body).delete_prefix(""))
538
552
  return data.to_h { |k, v| [k.to_s, js_string(v)] } if data.is_a?(Hash)
539
- return data.each_with_index.to_h { |v, i| [i.to_s, js_string(v)] } if data.is_a?(Array)
553
+ return BAD_BODY
540
554
  else
541
555
  return request.form.transform_values { |v| form_value(v) }
542
556
  end
543
557
  {}
544
558
  rescue StandardError
545
- {}
559
+ BAD_BODY
546
560
  end
547
561
 
548
562
  # String(value) for a parsed JSON value.
@@ -650,10 +664,40 @@ module Cronwatch
650
664
  @env["REQUEST_METHOD"].to_s.upcase
651
665
  end
652
666
 
653
- # The path as the browser asked for it, still percent-encoded, mount point included.
667
+ # The path as the browser asked for it, still percent-encoded, mount
668
+ # point included, with its dot segments resolved as `new URL` resolves
669
+ # them (a server may pass them through).
654
670
  def pathname
655
671
  path = "#{@env["SCRIPT_NAME"]}#{@env["PATH_INFO"]}"
656
- path.empty? ? "/" : path
672
+ path.empty? ? "/" : Request.resolve_dots(path)
673
+ end
674
+
675
+ # A segment the URL standard reads as "." or "..", "%2e" standing for a period.
676
+ SINGLE_DOT = /\A(?:\.|%2e)\z/i
677
+ DOUBLE_DOT = /\A(?:\.|%2e){2}\z/i
678
+
679
+ # `path` with its "." and ".." segments taken out as the URL standard
680
+ # does: ".." takes the segment before it with it, and either one last
681
+ # leaves a trailing slash.
682
+ def self.resolve_dots(path)
683
+ return path unless path.start_with?("/")
684
+
685
+ segments = path.split("/", -1).drop(1)
686
+ return path unless segments.any? { |segment| SINGLE_DOT.match?(segment) || DOUBLE_DOT.match?(segment) }
687
+
688
+ out = []
689
+ segments.each_with_index do |segment, i|
690
+ last = i == segments.length - 1
691
+ if DOUBLE_DOT.match?(segment)
692
+ out.pop
693
+ out << "" if last
694
+ elsif SINGLE_DOT.match?(segment)
695
+ out << "" if last
696
+ else
697
+ out << segment
698
+ end
699
+ end
700
+ "/#{out.join("/")}"
657
701
  end
658
702
 
659
703
  def header(name)
@@ -121,9 +121,10 @@ module Cronwatch
121
121
 
122
122
  # When the job was due within `from` to `to`. A cron's fires come from
123
123
  # Schedule.fires_between. An interval is due one period after each run
124
- # started, and after the last run once a period for as long as nothing
125
- # runs (the times a missed interval job keeps being asked for); with no
126
- # run yet, from its next expected time.
124
+ # started (after it finished, for a run that outlasted the period:
125
+ # Schedule.interval_due), and after the last run once a period for as
126
+ # long as nothing runs (the times a missed interval job keeps being
127
+ # asked for); with no run yet, from its next expected time.
127
128
  def due_times(job, parsed, runs, from, to)
128
129
  return DueTimes.new(times: [], dense: false) if parsed.nil?
129
130
 
@@ -131,13 +132,14 @@ module Cronwatch
131
132
  every = parsed.every_ms
132
133
  return DueTimes.new(times: [], dense: true) if (to - from).fdiv(every) > MAX_TICKS
133
134
 
134
- starts = runs.map(&:started_at).sort
135
+ # Oldest start first, runs that started together in the order given, as a stable sort leaves them.
136
+ ordered = runs.each_with_index.sort_by { |run, i| [run.started_at, i] }.map(&:first)
137
+ dues = ordered.map { |run| Schedule.interval_due(every, run.started_at, Evaluate.finished_at_of(run)) }
135
138
  times = []
136
- starts.each do |start|
137
- t = start + every
138
- times << t if t >= from && t <= to
139
+ dues.each do |due|
140
+ times << due if due >= from && due <= to
139
141
  end
140
- t = starts.empty? ? job.next_expected_at : starts.last + every
142
+ t = dues.empty? ? job.next_expected_at : dues.last
141
143
  unless t.nil?
142
144
  t += (from - t).fdiv(every).ceil * every if t < from
143
145
  while t <= to
@@ -160,8 +162,10 @@ module Cronwatch
160
162
  return nil if parsed.nil? || !open?(job, :missed)
161
163
 
162
164
  grace = safely(0) { Evaluate.grace_ms(job.definition) }
163
- last = job.last_run&.started_at
164
- return safely(nil) { Schedule.expectation(parsed, last, last, grace)&.due_at } unless last.nil?
165
+ last = job.last_run
166
+ unless last.nil?
167
+ return safely(nil) { Schedule.expectation(parsed, last.started_at, last.started_at, grace, Evaluate.finished_at_of(last))&.due_at }
168
+ end
165
169
 
166
170
  past = times.select { |t| t + grace < now }
167
171
  past.last
@@ -61,6 +61,9 @@ module Cronwatch
61
61
  guess = target + (target - civil_seconds(wall(target, timezone)))
62
62
  seen = wall(guess, timezone)
63
63
  if seen == wall
64
+ # Only an hour back is tried, as croner's fromTZ does, so a fall-back
65
+ # of another length (Lord Howe's 30 minutes) keeps croner's answer.
66
+ # Keep it so: Schedule's fall-back fires must match the SDK's.
64
67
  earlier = guess - 3600
65
68
  return wall(earlier, timezone) == wall ? earlier : guess
66
69
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips