Skip to content

Commit 768053e

Browse files
authored
Improve computation accuracy + add protection against infinite loop (#44)
1 parent b14d400 commit 768053e

9 files changed

+67
-23
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
.ruby-gemset
77
.ruby-version
88
Gemfile.lock
9+
gemfiles/*.lock
910
InstalledFiles
1011
_yardoc
1112
coverage

.travis.yml

+7-8
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
language: ruby
2+
dist: bionic
23
rvm:
3-
- 2.4.9
4-
- 2.5.7
5-
- 2.6.5
6-
- 2.7.0
4+
- 2.4.10
5+
- 2.5.8
6+
- 2.6.6
7+
- 2.7.2
78
- 3.0.0
89
- jruby-9.2.14.0
910
gemfile:
@@ -12,17 +13,15 @@ gemfile:
1213
- gemfiles/Gemfile.activesupport-6.x
1314
jobs:
1415
exclude:
15-
- rvm: 2.4.9
16+
- rvm: 2.4.10
1617
gemfile: gemfiles/Gemfile.activesupport-6.x
17-
- rvm: 2.7.0
18+
- rvm: 2.7.2
1819
gemfile: gemfiles/Gemfile.activesupport-4.x
1920
- rvm: 3.0.0
2021
gemfile: gemfiles/Gemfile.activesupport-4.x
2122
include:
2223
- rvm: ruby-head
2324
gemfile: gemfiles/Gemfile.activesupport-edge
24-
- rvm: jruby-head
25-
gemfile: gemfiles/Gemfile.activesupport-edge
2625
allow_failures:
2726
- gemfile: gemfiles/Gemfile.activesupport-edge
2827
fast_finish: true

CHANGELOG.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Unreleased
22

3-
[Compare master with v1.3.0](https://github.com/intrepidd/working_hours/compare/v1.3.0...master)
3+
[Compare master with v1.3.1](https://github.com/intrepidd/working_hours/compare/v1.3.1...master)
4+
5+
# v1.3.1
6+
* Improve computation accuracy in `advance_to_working_time` and `working_time_between` by using more exact (integer-based) time operations instead of floating point numbers - [#44](https://github.com/Intrepidd/working_hours/pull/44)
7+
* Raise an exception when we detect an infinite loops in `advance_to_working_time` to improve resilience and make debugging easier - [#44](https://github.com/Intrepidd/working_hours/pull/44)
8+
* Use a Rational number for the midnight value to avoid leaking sub-nanoseconds residue because of floating point accuracy - [#44](https://github.com/Intrepidd/working_hours/pull/44)
49

510
# v1.3.0
611
* Improve supports for fractional seconds in input times by only rounding results at the end - [#42](https://github.com/Intrepidd/working_hours/issues/42) [#43](https://github.com/Intrepidd/working_hours/pull/43)

gemfiles/Gemfile.activesupport-6.x

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ source 'https://rubygems.org'
22

33
gemspec :path => '..'
44

5-
gem 'activesupport', '~> 6.0'
5+
gem 'activesupport', '~> 6.1'

gemfiles/Gemfile.activesupport-edge

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ source 'https://rubygems.org'
22

33
gemspec :path => '..'
44

5-
gem 'activesupport', github: 'rails/rails'
5+
gem 'activesupport', github: 'rails/rails', branch: 'main'

lib/working_hours/computation.rb

+13-9
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def advance_to_working_time time, config: nil
8282
time_in_day = time.seconds_since_midnight
8383
config[:working_hours][time.wday].each do |from, to|
8484
return time if time_in_day >= from and time_in_day < to
85-
return time + (from - time_in_day) if from >= time_in_day
85+
return time.beginning_of_day + from if from >= time_in_day
8686
end
8787
# if none is found, go to next day and loop
8888
time = (time + 1.day).beginning_of_day
@@ -101,8 +101,7 @@ def advance_to_closing_time time, config: nil
101101
time_in_day = time.seconds_since_midnight
102102
time = time.beginning_of_day
103103
config[:working_hours][time.wday].each do |from, to|
104-
return time + to if time_in_day >= from and time_in_day < to
105-
return time + to if from >= time_in_day
104+
return time + to if time_in_day < to
106105
end
107106
# if none is found, go to next day and loop
108107
time = time + 1.day
@@ -183,20 +182,25 @@ def working_time_between from, to, config: nil
183182
to = in_config_zone(to, config: config)
184183
distance = 0
185184
while from < to
185+
from_was = from
186186
# look at working ranges
187187
time_in_day = from.seconds_since_midnight
188188
config[:working_hours][from.wday].each do |begins, ends|
189189
if time_in_day >= begins and time_in_day < ends
190-
# take all we can
191-
take = [ends - time_in_day, to - from].min
192-
# advance time
193-
from += take
194-
# increase counter
195-
distance += take
190+
if (to - from) > (ends - time_in_day)
191+
# take all the range and continue
192+
distance += (ends - time_in_day)
193+
from = from.beginning_of_day + ends
194+
else
195+
# take only what's needed and stop
196+
distance += (to - from)
197+
from = to
198+
end
196199
end
197200
end
198201
# roll to next business period
199202
from = advance_to_working_time(from, config: config)
203+
raise "Invalid loop detected in working_time_between (from=#{from.iso8601(12)}, to=#{to.iso8601(12)}, distance=#{distance}, config=#{config}), please open an issue ;)" unless from > from_was
200204
end
201205
distance.round # round up to supress miliseconds introduced by 24:00 hack
202206
end

lib/working_hours/config.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Config
77

88
TIME_FORMAT = /\A([0-2][0-9])\:([0-5][0-9])(?:\:([0-5][0-9]))?\z/
99
DAYS_OF_WEEK = [:sun, :mon, :tue, :wed, :thu, :fri, :sat]
10+
MIDNIGHT = Rational('86399.999999')
1011

1112
class << self
1213

@@ -115,7 +116,7 @@ def compile_time time
115116
sec = time[TIME_FORMAT,3].to_i
116117
time = hour * 3600 + min * 60 + sec
117118
# Converts 24:00 to 23:59:59.999999
118-
return 86399.999999 if time == 86400
119+
return MIDNIGHT if time == 86400
119120
time
120121
end
121122

lib/working_hours/version.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module WorkingHours
2-
VERSION = "1.3.0"
2+
VERSION = "1.3.1"
33
end

spec/working_hours/computation_spec.rb

+35-1
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@
173173
WorkingHours::Config.time_zone = 'Tokyo'
174174
expect(advance_to_working_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST')
175175
end
176+
177+
it 'do not leak nanoseconds when advancing' do
178+
expect(advance_to_working_time(Time.utc(2014, 4, 7, 5, 0, 0, 123456.789))).to eq(Time.utc(2014, 4, 7, 9, 0, 0, 0))
179+
end
176180
end
177181

178182
describe '#advance_to_closing_time' do
@@ -240,7 +244,7 @@
240244
end
241245

242246
let(:monday_morning) { Time.utc(2014, 4, 7, 0) }
243-
let(:monday_closing) { Time.utc(2014, 4, 7) + 86399.999999 }
247+
let(:monday_closing) { Time.utc(2014, 4, 7) + WorkingHours::Config::MIDNIGHT }
244248
let(:tuesday_closing) { Time.utc(2014, 4, 8, 17) }
245249
let(:sunday) { Time.utc(2014, 4, 6, 17) }
246250

@@ -255,6 +259,11 @@
255259
it 'moves over midnight' do
256260
expect(advance_to_closing_time(sunday)).to eq(monday_closing)
257261
end
262+
263+
it 'give precise computation with nothing other than miliseconds' do
264+
pending "iso8601 is not precise enough on AS < 4" if ActiveSupport::VERSION::MAJOR <= 4
265+
expect(advance_to_closing_time(monday_morning).iso8601(25)).to eq("2014-04-07T23:59:59.9999990000000000000000000Z")
266+
end
258267
end
259268

260269
it 'works with any input timezone (converts to config)' do
@@ -271,6 +280,10 @@
271280
WorkingHours::Config.time_zone = 'Tokyo'
272281
expect(advance_to_closing_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST')
273282
end
283+
284+
it 'do not leak nanoseconds when advancing' do
285+
expect(advance_to_closing_time(Time.utc(2014, 4, 7, 5, 0, 0, 123456.789))).to eq(Time.utc(2014, 4, 7, 17, 0, 0, 0))
286+
end
274287
end
275288

276289
describe '#next_working_time' do
@@ -544,6 +557,27 @@
544557
)).to eq(7.hours)
545558
end
546559

560+
it 'uses precise computation to avoid useless loops' do
561+
# +200 usec on each time, using floating point would cause
562+
# precision issues and require several iterations
563+
expect(self).to receive(:advance_to_working_time).twice.and_call_original
564+
expect(working_time_between(
565+
Time.utc(2014, 4, 7, 5, 0, 0, 200),
566+
Time.utc(2014, 4, 7, 15, 0, 0, 200),
567+
)).to eq(6.hours)
568+
end
569+
570+
it 'do not cause infinite loop if the time is not advancing properly' do
571+
# simulate some computation/precision error
572+
expect(self).to receive(:advance_to_working_time).twice do |time|
573+
time.change(hour: 9) - 0.0001
574+
end
575+
expect { working_time_between(
576+
Time.utc(2014, 4, 7, 5, 0, 0),
577+
Time.utc(2014, 4, 7, 15, 0, 0),
578+
) }.to raise_error(RuntimeError, /Invalid loop detected in working_time_between \(from=2014-04-07T08:59:59.999/)
579+
end
580+
547581
# generates two times with +0ms, +250ms, +500ms, +750ms and +1s
548582
# then for each combination compare the result with a ruby diff
549583
context 'with precise miliseconds timings' do

0 commit comments

Comments
 (0)