Project

General

Profile

Bug #17131

Updated by sawa (Tsuyoshi Sawada) over 3 years ago

## Problem 

 According `Time.at`'s promise is that being passed an argument that responds to `to_r`, [the result is deterministic and uses `to_r`](https://github.com/ruby/ruby/blob/445e5548c9da906a2d7a490e660328b2893d07d1/spec/ruby/core/time/at_spec.rb#L89). 
 It doesn't specify though what the [spec](https://github.com/ruby/ruby/blob/445e5548c9da906a2d7a490e660328b2893d07d1/spec/ruby/core/time/at_spec.rb#L89): 

 result should be, only that it should be consistent: 
 ```ruby 
     describe "with an argument that responds to #to_r" do 
       it "coerces using #to_r" do 
         o = mock_numeric('rational') 
         o.should_receive(:to_r).and_return(Rational(5, 2)) 
         Time.at(o).should == Time.at(Rational(5, 2)) 
       end 
     end 
 ``` 

 `Time.at` applies `to_r` to its argument if it responds to `to_r`. It doesn't specify though what This works quite well with the result should be. 

 It preserves the value with core `Time` class: 

 
 ```ruby 
 time = Time.now 
 time.nsec # => 110716000 
 time.nsec == Time.at(time).nsec # => true 
 ``` 

 ```ruby 
 time = Time.at(946684800, 123456789, :nsec) 
 time.nsec # => 123456789 
 Time.at(time).nsec # => 123456789 
 ``` 

 and Also works fine with stdlib's `DateTime` class: 

 
 ```ruby 
 require 'time' 
 dt = DateTime.now 
 dt.to_time.nsec # => 111439000 
 dt.to_time.nsec == Time.at(dt.to_time).nsec # => true 
 ``` 

 However,    `ActiveSupport::TimeWithZone`, which [mimics `Time`](https://github.com/rails/rails/blob/3ddf6b66bca0fd7f79a18864a1d260a3ab323404/activesupport/lib/active_support/time_with_zone.rb#L493) It gets more complicated with something that is neither a `Time` or a `DateTime`, but tries to look alike. 
 For example, `ActiveSupport::TimeWithZone` [that pretends to be `Time`](https://github.com/rails/rails/blob/3ddf6b66bca0fd7f79a18864a1d260a3ab323404/activesupport/lib/active_support/time_with_zone.rb#L493), but doesn't inherit from `Time` and rather [delegates [rather delegates to it](https://github.com/rails/rails/blob/3ddf6b66bca0fd7f79a18864a1d260a3ab323404/activesupport/lib/active_support/time_with_zone.rb#L188), does not preserve the value: it](https://github.com/rails/rails/blob/3ddf6b66bca0fd7f79a18864a1d260a3ab323404/activesupport/lib/active_support/time_with_zone.rb#L188). 

 ```ruby 
 t=Time.current 
 t.nsec # => 3882000 
 Time.at(t).nsec # => 3881931 
 ``` 

 This may look 
 Looks like a rounding issue: issue? 

 > The lowest digits of to_f and nsec are different because IEEE 754 double is not accurate enough to represent the exact number of nanoseconds since the Epoch. 

 Probably, yes. But that doesn't explain why a core `Time` class doesn't suffer from this rounding issue. 

 Also, here you can see there's no rounding at all, even for nanoseconds: 
 ```ruby 
 time = Time.at(946684800, 123456789, :nsec) 
 time.nsec # => 123456789 
 Time.at(time).nsec # => 123456789 
 ``` 


 ## Why? 

 The ecosystems have provided workarounds: 

 * Ecosystem has to provide workarounds https://github.com/travisjeffery/timecop/pull/70 https://github.com/rails/rails/pull/9403 
 * https://github.com/rails/rails/pull/9403 

 But they That obviously do not work because `to_r` `.to_r` is not getting called. And it spirals out to downright crutches 

 * https://github.com/rails/rails/pull/35713 

 and duct tape 

 * https://github.com/rspec/rspec-rails/pull/2304 

 
 This results in end user specs that do not handle nano-, micro-, or even milliseconds precision: 

 looking like that: 
 ```ruby 
 it 'is scheduled in 5 seconds' do 
   expect { SayHiJob.perform_in_five } 
     .to have_enqueued_job(SayHiJob).at(a_value_within(1.second).of(Time.current)) 
 end 
 ``` 

 A related As you can see, it's not nano-, micro-, or even milliseconds. 

 Related Rails issue is 

 * https://github.com/rails/rails/issues/38831 

 


 ## Investigation 

 With Let's start with a simpler example of a class that pretends to be coercible to `Time`: 

 
 ```ruby 
 RationalTime = Class.new do 
   def respond_to?(method_name, *) 
     puts method_name 
     super 
   end 
   def to_r 
     @r 
   end 
   def initialize(r) 
     @r = r 
   end 
 end 

 r = Rational(858710000, 3) 
 rt = RationalTime.new(r) 
 puts Time.at(rt) 
 ``` 

 the The result is rather frustrating: 

 
 ``` 
 to_r # <= `Time.at` checked if the class responds to `.to_r` 
 to_int # but also checked if it responds to `to_int`! 
 1.rb:19:in `at': can't convert RationalTime into an exact number (TypeError) 
         from 1.rb:19:in `<main>' 
 ``` 

 What have just happened? 

 It seems to fail at that the following test: 

 promise: 
 ```ruby 
     describe "with an argument that responds to #to_r" do 
       it "coerces using #to_r" do 
 ``` 

 
 has been broken. 


 Ok. We see that `respond_to?` was called for `to_int`. Let's implement it: 

 
 ```ruby 
 RationalTime = Class.new do 
   def to_r 
     Rational(1111, 3) 
   end 

   def to_int 
     100 
   end 
 end 

 puts Time.at(RationalTime.new).nsec # => 333333333 
 puts Time.at(RationalTime.new).to_f # => 370.3333333333333 
 ``` 

 The returned value is far away from Nothing resembling `100`, but it raises no more errors, and our rational part is finally used. 

 


 ## Nanoseconds rounding 

 `Time.now` returns time with no nanosecond precision, e.g.: 

 
 ```ruby 
 Time.now.nsec # => 225_852_000 
 ``` 

 Frankly, I do not know whether defining fail to make any conclusion if the addition of `is_a?` and `kind_of?` makes make any difference: 

 
 ```ruby 
 RationalTime = Class.new do 
   def is_a?(klass) 
     klass == ::Time || super 
   end 

   def kind_of?(klass) 
     klass == ::Time || super 
   end 

   def to_r 
     Rational(1111, 3) 
   end 

   def to_int 
     100 
   end 
 end 
 ``` 

 


 ## Digging deeper 

 The following All of the below is mostly guessing. guessing, as I'm not familiar with Ruby internals nor have had any hands-on C experience during last years. 

 According to [Ruby core spec that covers Time.at with a Rational](https://github.com/ruby/ruby/blob/e5db3da9d34f0a7595208863301c044b612adbed/spec/ruby/core/time/at_spec.rb#L26): 

 
 ```ruby 
     it "roundtrips a Rational produced by #to_r" do 
       t = Time.now() 
       t2 = Time.at(t.to_r) 

       t2.should == t 
       t2.usec.should == t.usec 
       t2.nsec.should == t.nsec 
     end 
 ``` 

 it It seems that `Time.at` copies over the underlying time data without modification with no modifications if the object passed as an argument to at is detected to be as `Time` ([Ruby source](https://github.com/ruby/ruby/blob/e5db3da9d34f0a7595208863301c044b612adbed/time.c#L2853): 

 
 ```C 
 else if (IsTimeval(time)) { 
   struct time_object *tobj, *tobj2; 
   GetTimeval(time, tobj); 
   t = time_new_timew(klass, tobj->timew); 
   GetTimeval(t, tobj2); 
   TZMODE_COPY(tobj2, tobj); 
 } 
 ``` 

 where `IsTimeval` is defined by: 

 Where `IsTimeval`: 
 ```C 
 #define IsTimeval(obj) rb_typeddata_is_kind_of((obj), &time_data_type) 
 ``` 

 where `rb_typeddata_is_kind_of` is [defined as](https://github.com/ruby/ruby/blob/aefb13eb631cc5cd784fe2fc10f1f333a2c5e68c/error.c#L888) 
 Where [`rb_typeddata_is_kind_of`](https://github.com/ruby/ruby/blob/aefb13eb631cc5cd784fe2fc10f1f333a2c5e68c/error.c#L888) (error.c?): 

 
 ```C 
 rb_typeddata_is_kind_of(VALUE obj, const rb_data_type_t *data_type) 
 { 
     if (!RB_TYPE_P(obj, T_DATA) || 
         !RTYPEDDATA_P(obj) || !rb_typeddata_inherited_p(RTYPEDDATA_TYPE(obj), data_type)) { 
         return 0; 
     } 
     return 1; 
 } 
 ``` 

 The hope that `IsTimeval` evaluates to `true` for an impersonating class is debunked by an experimental result: 

 
 ```ruby 
 RTInteger = Class.new do 
   def is_a?(klass) 
     klass == ::Time || super 
   end 

   def kind_of?(klass) 
     klass == ::Time || super 
   end 

   def to_int 
     100 
   end 
 end 

 Time.at(RTInteger.new) # => 1970-01-01 02:01:40 +0200 
 ``` 

 Our time passes through a series of lossy transformations ([1](https://github.com/ruby/ruby/blob/e5db3da9d34f0a7595208863301c044b612adbed/time.c#L2861), [2](https://github.com/ruby/ruby/blob/e5db3da9d34f0a7595208863301c044b612adbed/time.c#L314)): 

 
 ```C 
         timew = rb_time_magnify(v2w(num_exact(time))); 
         t = time_new_timew(klass, timew); 
 ``` 

 ```C 
 v2w(VALUE v) 
 { 
     if (RB_TYPE_P(v, T_RATIONAL)) { 
         if (RRATIONAL(v)->den != LONG2FIX(1)) 
             return WIDEVAL_WRAP(v); 
         v = RRATIONAL(v)->num; 
     } 
 ``` 

 There is a check for it to be a `Rational`, but I can't find where `respond_to?(:to_r)` call comes from. 
 In any case, it ends up deciding not to call `.to_r` for some reason. 

 


 ## Regression or undefined behavior? 

 Let's run the snippet from the beginning of this ticket with `RationalTime` on different Ruby versions/implementations. 

 
 ```ruby 
 RationalTime = Class.new do 
   def to_r 
     Rational(1111, 3) 
   end 

   def to_int 
     100 
   end 
 end 

 puts Time.at(RationalTime.new).nsec 
 puts Time.at(RationalTime.new).to_f 
 ``` 

 With `to_int` defined: 

 
 ```ruby 
 Time.at(RationalTime.new).nsec # => 333333333 
 Time.at(RationalTime.new).to_f # => 370.3333333333333 
 ``` 

 
 Without `to_int` defined: 

 
 ```ruby 
 TypeError: can't convert RationalTime into an exact number 
         from (irb):7:in `at' 
         from (irb):7 
         from /Users/pirj/.rvm/rubies/ruby-2.0.0-p648/bin/irb:12:in `<main>' 
 ``` 

 
 It's consistent from 2.0.0 throughout 2.7.0. 

 


 JRuby (jruby 9.3.0.0-SNAPSHOT (2.6.5) 2020-08-25 2f0c49000a OpenJDK 64-Bit Server VM 14.0.1+14 on 14.0.1+14 +jit [darwin-x86_64]): 
 with `to_int`: 

 
 ```ruby 
 Time.at(RationalTime.new).nsec # => 333333333 
 Time.at(RationalTime.new).to_f # => 370.33333333300004 
 ``` 

 Without 
 without `to_int`: 

 
 ``` 
 Traceback (most recent call last): 
        11: from /Users/pirj/.rvm/rubies/jruby-head/bin/jruby_executable_hooks:24:in `<main>' 
        10: from org/jruby/RubyKernel.java:1117:in `eval' 
         9: from /Users/pirj/.rvm/rubies/jruby-head/bin/irb:23:in `<main>' 
         8: from org/jruby/RubyKernel.java:1078:in `load' 
         7: from /Users/pirj/.rvm/rubies/jruby-head/lib/ruby/gems/shared/gems/irb-1.0.0/exe/irb:11:in `<main>' 
         6: from org/jruby/RubyKernel.java:1263:in `catch' 
         5: from org/jruby/RubyKernel.java:1263:in `catch' 
         4: from org/jruby/RubyKernel.java:1524:in `loop' 
         3: from org/jruby/RubyKernel.java:1117:in `eval' 
         2: from (irb):7:in `evaluate' 
         1: from org/jruby/RubyTime.java:1329:in `at' 
 TypeError (can't convert RationalTime into an exact number) 
 ``` 

 


 ## Additional note: precision 

 The spec: 

 
 ```ruby 
     describe "with an argument that responds to #to_r" do 
       it "coerces using #to_r" do 
         o = mock_numeric('rational') 
         o.should_receive(:to_r).and_return(Rational(5, 2)) 
         Time.at(o).should == Time.at(Rational(5, 2)) 
       end 
     end 
 ``` 

 doesn't 
 Doesn't provide much precision, as `Rational(5, 2)` is 2.5. Obviously, microseconds and nanoseconds are all zero, and this time it has 500 milliseconds. 
 `Rational(22, 7)` would yield more floating digits to be able to make assertions on nanosecond precision. 

 


 ## How to fix 

 The I might be missing something essential, but the point is to let teach `Time.at` to adhere to duck typing. "quacks like a duck" principle. 
 If the first and only argument to `Time.at` responds to `to_r`, it should be used, and `to_int` shouldn't be required, since it's not used down the lines anyway. 

 In addition to that, it would be really nice to adjust the specs so that if a `Rational` Rational or an object that responds to `to_r` is passed, then that all of its precision is kept, i.e: 

 
 ```ruby 
     describe "with an argument that responds to #to_r" do 
       it "coerces using #to_r" do 
         o = mock_numeric('rational') 
         o.should_receive(:to_r).and_return(Rational(22, 7)) 
         Time.at(o).should == Time.at(Rational(22, 7)) 
         Time.at(o).nsec.should == 142857142 
         Time.at(o).to_f.round(8).should == 3.14285714 
         Time.at(o).to_f.round(9).should == 3.142857143 # Not sure if JRuby is capable of this precision though 
       end 
     end 
 ``` 

Back