Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mighty-beans-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@livekit/protocol": patch
---

encode monotonic time in wall clock
37 changes: 35 additions & 2 deletions observability/gatewayobs/gen_reporter.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

65 changes: 59 additions & 6 deletions observability/gatewayobs/gen_reporter_noop.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 17 additions & 28 deletions utils/mono/mono.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.

// Package mono enforces use of monotonic time when creating/parsing time.Time from external sources.
//
// Using time.Now produces monotonic time values that correctly measure time difference in the presence of clock resets.
//
// On the other hand, time produce by time.Unix or time.Parse doesn't have this property. Clock reset may lead to incorrect
// durations computed from these timestamps. To fix this, prefer using Unix and Parse provided by this package.
//
// Monotonic time could also be erased when using functions like Truncate, Round, In, UTC. Be careful when using these.
// These APIs encode monotonic time into time.Time wall-clock fields. Returned
// values intentionally do not carry Go's internal monotonic payload and are
// meant to be compared with other mono timestamps.
//
// More details: https://go.googlesource.com/proposal/+/master/design/12914-monotonic.md
package mono
Expand All @@ -38,37 +33,31 @@ func resetClock() {
epochNano = epoch.UnixNano()
}

// jumpClock adjusts reference timestamp by a given duration emulating a clock reset/jump.
// Used in tests only.
// jumpClock adjusts reference timestamp by a given duration emulating a clock
// reset/jump. Used in tests only.
func jumpClock(dt time.Duration) {
epoch = epoch.Add(-dt) // we pretend time.Now() jumps, not the reference
epochNano = epoch.UnixNano()
}

// FromTime ensures that time.Time value uses monotonic clock.
//
// Deprecated: You should probably use Unix or Parse instead.
// FromTime creates a Time from the monotonic part of t. Note that the monotonic
// part of t could have been erased when using functions like Truncate, Round,
// In, UTC, etc... Be careful when using this
func FromTime(t time.Time) time.Time {
return fromTime(t)
}

func fromTime(t time.Time) time.Time {
if t.IsZero() {
return time.Time{}
}
return epoch.Add(t.Sub(epoch))
return time.Unix(0, epochNano+int64(t.Sub(epoch)))
}

// Now is a wrapper for time.Time.
//
// Deprecated: time.Now always uses monotonic clock.
// Now creates a monotonic time without reading the system wall clock
func Now() time.Time {
return time.Now()
return time.Unix(0, epochNano+int64(time.Since(epoch)))
}

// Unix is an analog of time.Unix that produces monotonic time.
func Unix(sec, nsec int64) time.Time {
return fromTime(time.Unix(sec, nsec))
return FromTime(time.Unix(sec, nsec))
}

// Parse is an analog of time.Parse that produces monotonic time.
Expand All @@ -77,17 +66,17 @@ func Parse(layout, value string) (time.Time, error) {
if err != nil {
return time.Time{}, err
}
return fromTime(t), nil
return FromTime(t), nil
}

// UnixNano returns the number of nanoseconds elapsed, based on the application start time.
// This value may be different from time.Now().UnixNano() in the presence of time resets.
// UnixNano returns the number of nanoseconds elapsed, based on the application
// start time.
func UnixNano() int64 {
return epochNano + int64(time.Since(epoch))
}

// UnixMicro returns the number of microseconds elapsed, based on the application start time.
// This value may be different from time.Now().UnixMicro() in the presence of time resets.
// UnixMicro returns the number of microseconds elapsed, based on the
// application start time.
func UnixMicro() int64 {
return UnixNano() / 1000
}
67 changes: 63 additions & 4 deletions utils/mono/mono_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,83 @@ import (

func TestMonoZero(t *testing.T) {
ts := time.Time{}
ts2 := fromTime(ts)
ts2 := FromTime(ts)
require.True(t, ts.IsZero())
require.True(t, ts2.IsZero())
require.True(t, ts.Equal(ts2))
require.Equal(t, ts.String(), ts2.String())
}

func TestMono(t *testing.T) {
t.Cleanup(resetClock) // restore
t.Cleanup(resetClock)

ts1 := time.Now()
ts2 := ts1.Add(time.Second)

ts1m := fromTime(ts1)
ts1m := FromTime(ts1)
// emulate a clock reset, +1h jump
// TODO: use synctest when we switch to Go 1.25
jumpClock(time.Hour)
ts2m := fromTime(ts2)
ts2m := FromTime(ts2)

require.Equal(t, ts2.Sub(ts1), ts2m.Sub(ts1m))
}

func TestNoGoMonotonicPayload(t *testing.T) {
t.Cleanup(resetClock)

now := Now()
fromTime := FromTime(time.Now())
fromUnix := Unix(123, 456)
fromParse, err := Parse(time.RFC3339Nano, "2026-03-05T12:34:56.789123456Z")
require.NoError(t, err)

require.Equal(t, now, now.Round(0))
require.Equal(t, fromTime, fromTime.Round(0))
require.Equal(t, fromUnix, fromUnix.Round(0))
require.Equal(t, fromParse, fromParse.Round(0))
}

func TestSerializationRoundTripComparableAcrossClockJump(t *testing.T) {
t.Cleanup(resetClock)

t1 := Now()
t2 := t1.Add(10 * time.Second)

t1Encoded := t1.UnixNano()
t2Encoded := t2.UnixNano()

// emulate a clock reset, +1h jump
jumpClock(time.Hour)

t1Decoded := Unix(0, t1Encoded)
t2Decoded := Unix(0, t2Encoded)

require.Equal(t, t2.Sub(t1), t2Decoded.Sub(t1Decoded))
}

func TestNowProgressesAcrossClockJump(t *testing.T) {
t.Cleanup(resetClock)

t1 := Now()
time.Sleep(time.Millisecond)

// emulate a clock reset, +1h jump
jumpClock(time.Hour)
t2 := Now()

require.Greater(t, t2.Sub(t1), time.Duration(0))
}

func BenchmarkTime(b *testing.B) {
b.Run("Now()", func(b *testing.B) {
for b.Loop() {
_ = Now()
}
})
b.Run("time.Now()", func(b *testing.B) {
for b.Loop() {
_ = time.Now()
}
})
}
Loading