From 6acb51d48518f247ec75b5b235cef57390f12811 Mon Sep 17 00:00:00 2001 From: George Robinson Date: Wed, 17 Jul 2024 08:44:27 +0100 Subject: [PATCH] chore: Use github.com/coder/quartz instead of time (#13542) --- go.mod | 5 +- go.sum | 2 + pkg/storage/wal/manager.go | 14 +- pkg/storage/wal/manager_test.go | 13 +- vendor/github.com/coder/quartz/.gitignore | 1 + vendor/github.com/coder/quartz/LICENSE | 121 ++++ vendor/github.com/coder/quartz/README.md | 632 +++++++++++++++++++++ vendor/github.com/coder/quartz/clock.go | 43 ++ vendor/github.com/coder/quartz/mock.go | 646 ++++++++++++++++++++++ vendor/github.com/coder/quartz/real.go | 80 +++ vendor/github.com/coder/quartz/ticker.go | 75 +++ vendor/github.com/coder/quartz/timer.go | 81 +++ vendor/modules.txt | 3 + 13 files changed, 1707 insertions(+), 9 deletions(-) create mode 100644 vendor/github.com/coder/quartz/.gitignore create mode 100644 vendor/github.com/coder/quartz/LICENSE create mode 100644 vendor/github.com/coder/quartz/README.md create mode 100644 vendor/github.com/coder/quartz/clock.go create mode 100644 vendor/github.com/coder/quartz/mock.go create mode 100644 vendor/github.com/coder/quartz/real.go create mode 100644 vendor/github.com/coder/quartz/ticker.go create mode 100644 vendor/github.com/coder/quartz/timer.go diff --git a/go.mod b/go.mod index 3d5aae7fd9e9..6387a6ebab06 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/grafana/loki/v3 -go 1.21 +go 1.21.8 -toolchain go1.21.3 +toolchain go1.22.4 require ( cloud.google.com/go/bigtable v1.18.1 @@ -120,6 +120,7 @@ require ( github.com/IBM/ibm-cos-sdk-go v1.10.0 github.com/axiomhq/hyperloglog v0.0.0-20240124082744-24bca3a5b39b github.com/buger/jsonparser v1.1.1 + github.com/coder/quartz v0.1.0 github.com/d4l3k/messagediff v1.2.1 github.com/dolthub/swiss v0.2.1 github.com/efficientgo/core v1.0.0-rc.2 diff --git a/go.sum b/go.sum index 002786d54296..ea4dc9a96737 100644 --- a/go.sum +++ b/go.sum @@ -452,6 +452,8 @@ github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa/go.mod h1:x/1Gn8zydmfq github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= +github.com/coder/quartz v0.1.0 h1:cLL+0g5l7xTf6ordRnUMMiZtRE8Sq5LxpghS63vEXrQ= +github.com/coder/quartz v0.1.0/go.mod h1:vsiCc+AHViMKH2CQpGIpFgdHIEQsxwm8yCscqKmzbRA= github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/fifo v1.0.0 h1:6PirWBr9/L7GDamKr+XM0IeUFXu5mf3M/BPpH9gaLBU= github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4= diff --git a/pkg/storage/wal/manager.go b/pkg/storage/wal/manager.go index cfc89605cb7c..196d02296b5f 100644 --- a/pkg/storage/wal/manager.go +++ b/pkg/storage/wal/manager.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/coder/quartz" "github.com/prometheus/prometheus/model/labels" "github.com/grafana/loki/v3/pkg/logproto" @@ -115,9 +116,11 @@ type Manager struct { // flushed, the segment is reset and moved to the back of the available // list to accept writes again. pending *list.List + closed bool + mu sync.Mutex - closed bool - mu sync.Mutex + // Used in tests. + clock quartz.Clock } // item is similar to PendingItem, but it is an internal struct used in the @@ -145,6 +148,7 @@ func NewManager(cfg Config, metrics *Metrics) (*Manager, error) { metrics: metrics.ManagerMetrics, available: list.New(), pending: list.New(), + clock: quartz.NewReal(), } m.metrics.NumPending.Set(0) m.metrics.NumFlushing.Set(0) @@ -177,12 +181,12 @@ func (m *Manager) Append(r AppendRequest) (*AppendResult, error) { // This is the first append to the segment. This time will be used in // know when the segment has exceeded its maximum age and should be // moved to the pending list. - it.firstAppendedAt = time.Now() + it.firstAppendedAt = m.clock.Now() } it.w.Append(r.TenantID, r.LabelsStr, r.Labels, r.Entries) // If the segment exceeded the maximum age or the maximum size, move it to // the closed list to be flushed. - if time.Since(it.firstAppendedAt) >= m.cfg.MaxAge || it.w.InputSize() >= m.cfg.MaxSegmentSize { + if m.clock.Since(it.firstAppendedAt) >= m.cfg.MaxAge || it.w.InputSize() >= m.cfg.MaxSegmentSize { m.pending.PushBack(it) m.metrics.NumPending.Inc() m.available.Remove(el) @@ -218,7 +222,7 @@ func (m *Manager) NextPending() (*PendingItem, error) { // should be moved to the pending list. el := m.available.Front() it := el.Value.(*item) - if !it.firstAppendedAt.IsZero() && time.Since(it.firstAppendedAt) >= m.cfg.MaxAge { + if !it.firstAppendedAt.IsZero() && m.clock.Since(it.firstAppendedAt) >= m.cfg.MaxAge { m.pending.PushBack(it) m.metrics.NumPending.Inc() m.available.Remove(el) diff --git a/pkg/storage/wal/manager_test.go b/pkg/storage/wal/manager_test.go index f5fee51ff166..ef14b2420108 100644 --- a/pkg/storage/wal/manager_test.go +++ b/pkg/storage/wal/manager_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/coder/quartz" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/prometheus/prometheus/model/labels" @@ -94,6 +95,10 @@ func TestManager_AppendMaxAge(t *testing.T) { }, NewMetrics(nil)) require.NoError(t, err) + // Create a mock clock. + clock := quartz.NewMock(t) + m.clock = clock + // Append 1B of data. lbs := labels.Labels{{Name: "a", Value: "b"}} entries := []*logproto.Entry{{Timestamp: time.Now(), Line: "c"}} @@ -112,7 +117,7 @@ func TestManager_AppendMaxAge(t *testing.T) { require.Equal(t, 0, m.pending.Len()) // Wait 100ms and append some more data. - time.Sleep(100 * time.Millisecond) + clock.Advance(100 * time.Millisecond) entries = []*logproto.Entry{{Timestamp: time.Now(), Line: "c"}} res, err = m.Append(AppendRequest{ TenantID: "1", @@ -325,6 +330,10 @@ func TestManager_NexPendingMaxAge(t *testing.T) { }, NewMetrics(nil)) require.NoError(t, err) + // Create a mock clock. + clock := quartz.NewMock(t) + m.clock = clock + // Append 1B of data. lbs := labels.Labels{{Name: "a", Value: "b"}} entries := []*logproto.Entry{{Timestamp: time.Now(), Line: "c"}} @@ -347,7 +356,7 @@ func TestManager_NexPendingMaxAge(t *testing.T) { // Wait 100ms. The segment that was just appended to should have reached // the maximum age. - time.Sleep(100 * time.Millisecond) + clock.Advance(100 * time.Millisecond) it, err = m.NextPending() require.NoError(t, err) require.NotNil(t, it) diff --git a/vendor/github.com/coder/quartz/.gitignore b/vendor/github.com/coder/quartz/.gitignore new file mode 100644 index 000000000000..62c893550adb --- /dev/null +++ b/vendor/github.com/coder/quartz/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/vendor/github.com/coder/quartz/LICENSE b/vendor/github.com/coder/quartz/LICENSE new file mode 100644 index 000000000000..f7c5d7fee65c --- /dev/null +++ b/vendor/github.com/coder/quartz/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be + protected by copyright and related or neighboring rights ("Copyright and + Related Rights"). Copyright and Related Rights include, but are not + limited to, the following: + +i. the right to reproduce, adapt, distribute, perform, display, +communicate, and translate a Work; +ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or +likeness depicted in a Work; +iv. rights protecting against unfair competition in regards to a Work, +subject to the limitations in paragraph 4(a), below; +v. rights protecting the extraction, dissemination, use and reuse of data +in a Work; +vi. database rights (such as those arising under Directive 96/9/EC of the +European Parliament and of the Council of 11 March 1996 on the legal +protection of databases, and under any national implementation +thereof, including any amended or successor version of such +directive); and +vii. other similar, equivalent or corresponding rights throughout the +world based on applicable law or treaty, and any national +implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention + of, applicable law, Affirmer hereby overtly, fully, permanently, + irrevocably and unconditionally waives, abandons, and surrenders all of + Affirmer's Copyright and Related Rights and associated claims and causes + of action, whether now known or unknown (including existing as well as + future claims and causes of action), in the Work (i) in all territories + worldwide, (ii) for the maximum duration provided by applicable law or + treaty (including future time extensions), (iii) in any current or future + medium and for any number of copies, and (iv) for any purpose whatsoever, + including without limitation commercial, advertising or promotional + purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each + member of the public at large and to the detriment of Affirmer's heirs and + successors, fully intending that such Waiver shall not be subject to + revocation, rescission, cancellation, termination, or any other legal or + equitable action to disrupt the quiet enjoyment of the Work by the public + as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason + be judged legally invalid or ineffective under applicable law, then the + Waiver shall be preserved to the maximum extent permitted taking into + account Affirmer's express Statement of Purpose. In addition, to the + extent the Waiver is so judged Affirmer hereby grants to each affected + person a royalty-free, non transferable, non sublicensable, non exclusive, + irrevocable and unconditional license to exercise Affirmer's Copyright and + Related Rights in the Work (i) in all territories worldwide, (ii) for the + maximum duration provided by applicable law or treaty (including future + time extensions), (iii) in any current or future medium and for any number + of copies, and (iv) for any purpose whatsoever, including without + limitation commercial, advertising or promotional purposes (the + "License"). The License shall be deemed effective as of the date CC0 was + applied by Affirmer to the Work. Should any part of the License for any + reason be judged legally invalid or ineffective under applicable law, such + partial invalidity or ineffectiveness shall not invalidate the remainder + of the License, and in such case Affirmer hereby affirms that he or she + will not (i) exercise any of his or her remaining Copyright and Related + Rights in the Work or (ii) assert any associated claims and causes of + action with respect to the Work, in either case contrary to Affirmer's + express Statement of Purpose. + +4. Limitations and Disclaimers. + +a. No trademark or patent rights held by Affirmer are waived, abandoned, +surrendered, licensed or otherwise affected by this document. +b. Affirmer offers the Work as-is and makes no representations or +warranties of any kind concerning the Work, express, implied, +statutory or otherwise, including without limitation warranties of +title, merchantability, fitness for a particular purpose, non +infringement, or the absence of latent or other defects, accuracy, or +the present or absence of errors, whether or not discoverable, all to +the greatest extent permissible under applicable law. +c. Affirmer disclaims responsibility for clearing rights of other persons +that may apply to the Work or any use thereof, including without +limitation any person's Copyright and Related Rights in the Work. +Further, Affirmer disclaims responsibility for obtaining any necessary +consents, permissions or other rights required for any use of the +Work. +d. Affirmer understands and acknowledges that Creative Commons is not a +party to this document and has no duty or obligation with respect to +this CC0 or use of the Work. \ No newline at end of file diff --git a/vendor/github.com/coder/quartz/README.md b/vendor/github.com/coder/quartz/README.md new file mode 100644 index 000000000000..8d467af12535 --- /dev/null +++ b/vendor/github.com/coder/quartz/README.md @@ -0,0 +1,632 @@ +# Quartz + +A Go time testing library for writing deterministic unit tests + +Our high level goal is to write unit tests that + +1. execute quickly +2. don't flake +3. are straightforward to write and understand + +For tests to execute quickly without flakes, we want to focus on _determinism_: the test should run +the same each time, and it should be easy to force the system into a known state (no races) before +executing test assertions. `time.Sleep`, `runtime.Gosched()`, and +polling/[Eventually](https://pkg.go.dev/github.com/stretchr/testify/assert#Eventually) are all +symptoms of an inability to do this easily. + +## Usage + +### `Clock` interface + +In your application code, maintain a reference to a `quartz.Clock` instance to start timers and +tickers, instead of the bare `time` standard library. + +```go +import "github.com/coder/quartz" + +type Component struct { + ... + + // for testing + clock quartz.Clock +} +``` + +Whenever you would call into `time` to start a timer or ticker, call `Component`'s `clock` instead. + +In production, set this clock to `quartz.NewReal()` to create a clock that just transparently passes +through to the standard `time` library. + +### Mocking + +In your tests, you can use a `*Mock` to control the tickers and timers your code under test gets. + +```go +import ( + "testing" + "github.com/coder/quartz" +) + +func TestComponent(t *testing.T) { + mClock := quartz.NewMock(t) + comp := &Component{ + ... + clock: mClock, + } +} +``` + +The `*Mock` clock starts at Jan 1, 2024, 00:00 UTC by default, but you can set any start time you'd like prior to your test. + +```go +mClock := quartz.NewMock(t) +mClock.Set(time.Date(2021, 6, 18, 12, 0, 0, 0, time.UTC)) // June 18, 2021 @ 12pm UTC +``` + +#### Advancing the clock + +Once you begin setting timers or tickers, you cannot change the time backward, only advance it +forward. You may continue to use `Set()`, but it is often easier and clearer to use `Advance()`. + +For example, with a timer: + +```go +fired := false + +tmr := mClock.Afterfunc(time.Second, func() { + fired = true +}) +mClock.Advance(time.Second) +``` + +When you call `Advance()` it immediately moves the clock forward the given amount, and triggers any +tickers or timers that are scheduled to happen at that time. Any triggered events happen on separate +goroutines, so _do not_ immediately assert the results: + +```go +fired := false + +tmr := mClock.Afterfunc(time.Second, func() { + fired = true +}) +mClock.Advance(time.Second) + +// RACE CONDITION, DO NOT DO THIS! +if !fired { + t.Fatal("didn't fire") +} +``` + +`Advance()` (and `Set()` for that matter) return an `AdvanceWaiter` object you can use to wait for +all triggered events to complete. + +```go +fired := false +// set a test timeout so we don't wait the default `go test` timeout for a failure +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + +tmr := mClock.Afterfunc(time.Second, func() { + fired = true +}) + +w := mClock.Advance(time.Second) +err := w.Wait(ctx) +if err != nil { + t.Fatal("AfterFunc f never completed") +} +if !fired { + t.Fatal("didn't fire") +} +``` + +The construction of waiting for the triggered events and failing the test if they don't complete is +very common, so there is a shorthand: + +```go +w := mClock.Advance(time.Second) +err := w.Wait(ctx) +if err != nil { + t.Fatal("AfterFunc f never completed") +} +``` + +is equivalent to: + +```go +w := mClock.Advance(time.Second) +w.MustWait(ctx) +``` + +or even more briefly: + +```go +mClock.Advance(time.Second).MustWait(ctx) +``` + +### Advance only to the next event + +One important restriction on advancing the clock is that you may only advance forward to the next +timer or ticker event and no further. The following will result in a test failure: + +```go +func TestAdvanceTooFar(t *testing.T) { + ctx, cancel := context.WithTimeout(10*time.Second) + defer cancel() + mClock := quartz.NewMock(t) + var firedAt time.Time + mClock.AfterFunc(time.Second, func() { + firedAt := mClock.Now() + }) + mClock.Advance(2*time.Second).MustWait(ctx) +} +``` + +This is a deliberate design decision to allow `Advance()` to immediately and synchronously move the +clock forward (even without calling `Wait()` on returned waiter). This helps meet Quartz's design +goals of writing deterministic and easy to understand unit tests. It also allows the clock to be +advanced, deterministically _during_ the execution of a tick or timer function, as explained in the +next sections on Traps. + +Advancing multiple events can be accomplished via looping. E.g. if you have a 1-second ticker + +```go +for i := 0; i < 10; i++ { + mClock.Advance(time.Second).MustWait(ctx) +} +``` + +will advance 10 ticks. + +If you don't know or don't want to compute the time to the next event, you can use `AdvanceNext()`. + +```go +d, w := mClock.AdvanceNext() +w.MustWait(ctx) +// d contains the duration we advanced +``` + +`d, ok := Peek()` returns the duration until the next event, if any (`ok` is `true`). You can use +this to advance a specific time, regardless of the tickers and timer events: + +```go +desired := time.Minute // time to advance +for desired > 0 { + p, ok := mClock.Peek() + if !ok || p > desired { + mClock.Advance(desired).MustWait(ctx) + break + } + mClock.Advance(p).MustWait(ctx) + desired -= p +} +``` + +### Traps + +A trap allows you to match specific calls into the library while mocking, block their return, +inspect their arguments, then release them to allow them to return. They help you write +deterministic unit tests even when the code under test executes asynchronously from the test. + +You set your traps prior to executing code under test, and then wait for them to be triggered. + +```go +func TestTrap(t *testing.T) { + ctx, cancel := context.WithTimeout(10*time.Second) + defer cancel() + mClock := quartz.NewMock(t) + trap := mClock.Trap().AfterFunc() + defer trap.Close() // stop trapping AfterFunc calls + + count := 0 + go mClock.AfterFunc(time.Hour, func(){ + count++ + }) + call := trap.MustWait(ctx) + call.Release() + if call.Duration != time.Hour { + t.Fatal("wrong duration") + } + + // Now that the async call to AfterFunc has occurred, we can advance the clock to trigger it + mClock.Advance(call.Duration).MustWait(ctx) + if count != 1 { + t.Fatal("wrong count") + } +} +``` + +In this test, the trap serves 2 purposes. Firstly, it allows us to capture and assert the duration +passed to the `AfterFunc` call. Secondly, it prevents a race between setting the timer and advancing +it. Since these things happen on different goroutines, if `Advance()` completes before +`AfterFunc()` is called, then the timer never pops in this test. + +Any untrapped calls immediately complete using the current time, and calling `Close()` on a trap +causes the mock clock to stop trapping those calls. + +You may also `Advance()` the clock between trapping a call and releasing it. The call uses the +current (mocked) time at the moment it is released. + +```go +func TestTrap2(t *testing.T) { + ctx, cancel := context.WithTimeout(10*time.Second) + defer cancel() + mClock := quartz.NewMock(t) + trap := mClock.Trap().Now() + defer trap.Close() // stop trapping AfterFunc calls + + var logs []string + done := make(chan struct{}) + go func(clk quartz.Clock){ + defer close(done) + start := clk.Now() + phase1() + p1end := clk.Now() + logs = append(fmt.Sprintf("Phase 1 took %s", p1end.Sub(start).String())) + phase2() + p2end := clk.Now() + logs = append(fmt.Sprintf("Phase 2 took %s", p2end.Sub(p1end).String())) + }(mClock) + + // start + trap.MustWait(ctx).Release() + // phase 1 + call := trap.MustWait(ctx) + mClock.Advance(3*time.Second).MustWait(ctx) + call.Release() + // phase 2 + call = trap.MustWait(ctx) + mClock.Advance(5*time.Second).MustWait(ctx) + call.Release() + + <-done + // Now logs contains []string{"Phase 1 took 3s", "Phase 2 took 5s"} +} +``` + +### Tags + +When multiple goroutines in the code under test call into the Clock, you can use `tags` to +distinguish them in your traps. + +```go +trap := mClock.Trap.Now("foo") // traps any calls that contain "foo" +defer trap.Close() + +foo := make(chan time.Time) +go func(){ + foo <- mClock.Now("foo", "bar") +}() +baz := make(chan time.Time) +go func(){ + baz <- mClock.Now("baz") +}() +call := trap.MustWait(ctx) +mClock.Advance(time.Second).MustWait(ctx) +call.Release() +// call.Tags contains []string{"foo", "bar"} + +gotFoo := <-foo // 1s after start +gotBaz := <-baz // ?? never trapped, so races with Advance() +``` + +Tags appear as an optional suffix on all `Clock` methods (type `...string`) and are ignored entirely +by the real clock. They also appear on all methods on returned timers and tickers. + +## Recommended Patterns + +### Options + +We use the Option pattern to inject the mock clock for testing, keeping the call signature in +production clean. The option pattern is compatible with other optional fields as well. + +```go +type Option func(*Thing) + +// WithTestClock is used in tests to inject a mock Clock +func WithTestClock(clk quartz.Clock) Option { + return func(t *Thing) { + t.clock = clk + } +} + +func NewThing(, opts ...Option) *Thing { + t := &Thing{ + ... + clock: quartz.NewReal() + } + for _, o := range opts { + o(t) + } + return t +} +``` + +In tests, this becomes + +```go +func TestThing(t *testing.T) { + mClock := quartz.NewMock(t) + thing := NewThing(, WithTestClock(mClock)) + ... +} +``` + +### Tagging convention + +Tag your `Clock` method calls as: + +```go +func (c *Component) Method() { + now := c.clock.Now("Component", "Method") +} +``` + +or + +```go +func (c *Component) Method() { + start := c.clock.Now("Component", "Method", "start") + ... + end := c.clock.Now("Component", "Method", "end") +} +``` + +This makes it much less likely that code changes that introduce new components or methods will spoil +existing unit tests. + +## Why another time testing library? + +Writing good unit tests for components and functions that use the `time` package is difficult, even +though several open source libraries exist. In building Quartz, we took some inspiration from + +- [github.com/benbjohnson/clock](https://github.com/benbjohnson/clock) +- Tailscale's [tstest.Clock](https://github.com/coder/tailscale/blob/main/tstest/clock.go) +- [github.com/aspenmesh/tock](https://github.com/aspenmesh/tock) + +Quartz shares the high level design of a `Clock` interface that closely resembles the functions in +the `time` standard library, and a "real" clock passes thru to the standard library in production, +while a mock clock gives precise control in testing. + +As mentioned in our introduction, our high level goal is to write unit tests that + +1. execute quickly +2. don't flake +3. are straightforward to write and understand + +For several reasons, this is a tall order when it comes to code that depends on time, and we found +the existing libraries insufficient for our goals. + +### Preventing test flakes + +The following example comes from the README from benbjohnson/clock: + +```go +mock := clock.NewMock() +count := 0 + +// Kick off a timer to increment every 1 mock second. +go func() { + ticker := mock.Ticker(1 * time.Second) + for { + <-ticker.C + count++ + } +}() +runtime.Gosched() + +// Move the clock forward 10 seconds. +mock.Add(10 * time.Second) + +// This prints 10. +fmt.Println(count) +``` + +The first race condition is fairly obvious: moving the clock forward 10 seconds may generate 10 +ticks on the `ticker.C` channel, but there is no guarantee that `count++` executes before +`fmt.Println(count)`. + +The second race condition is more subtle, but `runtime.Gosched()` is the tell. Since the ticker +is started on a separate goroutine, there is no guarantee that `mock.Ticker()` executes before +`mock.Add()`. `runtime.Gosched()` is an attempt to get this to happen, but it makes no hard +promises. On a busy system, especially when running tests in parallel, this can flake, advance the +time 10 seconds first, then start the ticker and never generate a tick. + +Let's talk about how Quartz tackles these problems. + +In our experience, an extremely common use case is creating a ticker then doing a 2-arm `select` +with ticks in one and context expiring in another, i.e. + +```go +t := time.NewTicker(duration) +for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + err := do() + if err != nil { + return err + } + } +} +``` + +In Quartz, we refactor this to be more compact and testing friendly: + +```go +t := clock.TickerFunc(ctx, duration, do) +return t.Wait() +``` + +This affords the mock `Clock` the ability to explicitly know when processing of a tick is finished +because it's wrapped in the function passed to `TickerFunc` (`do()` in this example). + +In Quartz, when you advance the clock, you are returned an object you can `Wait()` on to ensure all +ticks and timers triggered are finished. This solves the first race condition in the example. + +(As an aside, we still support a traditional standard library-style `Ticker`. You may find it useful +if you want to keep your code as close as possible to the standard library, or if you need to use +the channel in a larger `select` block. In that case, you'll have to find some other mechanism to +sync tick processing to your test code.) + +To prevent race conditions related to the starting of the ticker, Quartz allows you to set "traps" +for calls that access the clock. + +```go +func TestTicker(t *testing.T) { + mClock := quartz.NewMock(t) + trap := mClock.Trap().TickerFunc() + defer trap.Close() // stop trapping at end + go runMyTicker(mClock) // async calls TickerFunc() + call := trap.Wait(context.Background()) // waits for a call and blocks its return + call.Release() // allow the TickerFunc() call to return + // optionally check the duration using call.Duration + // Move the clock forward 1 tick + mClock.Advance(time.Second).MustWait(context.Background()) + // assert results of the tick +} +``` + +Trapping and then releasing the call to `TickerFunc()` ensures the ticker is started at a +deterministic time, so our calls to `Advance()` will have a predictable effect. + +Take a look at `TestExampleTickerFunc` in `example_test.go` for a complete worked example. + +### Complex time dependence + +Another difficult issue to handle when unit testing is when some code under test makes multiple +calls that depend on the time, and you want to simulate some time passing between them. + +A very basic example is measuring how long something took: + +```go +var measurement time.Duration +go func(clock quartz.Clock) { + start := clock.Now() + doSomething() + measurement = clock.Since(start) +}(mClock) + +// how to get measurement to be, say, 5 seconds? +``` + +The two calls into the clock happen asynchronously, so we need to be able to advance the clock after +the first call to `Now()` but before the call to `Since()`. Doing this with the libraries we +mentioned above means that you have to be able to mock out or otherwise block the completion of +`doSomething()`. + +But, with the trap functionality we mentioned in the previous section, you can deterministically +control the time each call sees. + +```go +trap := mClock.Trap().Since() +var measurement time.Duration +go func(clock quartz.Clock) { + start := clock.Now() + doSomething() + measurement = clock.Since(start) +}(mClock) + +c := trap.Wait(ctx) +mClock.Advance(5*time.Second) +c.Release() +``` + +We wait until we trap the `clock.Since()` call, which implies that `clock.Now()` has completed, then +advance the mock clock 5 seconds. Finally, we release the `clock.Since()` call. Any changes to the +clock that happen _before_ we release the call will be included in the time used for the +`clock.Since()` call. + +As a more involved example, consider an inactivity timeout: we want something to happen if there is +no activity recorded for some period, say 10 minutes in the following example: + +```go +type InactivityTimer struct { + mu sync.Mutex + activity time.Time + clock quartz.Clock +} + +func (i *InactivityTimer) Start() { + i.mu.Lock() + defer i.mu.Unlock() + next := i.clock.Until(i.activity.Add(10*time.Minute)) + t := i.clock.AfterFunc(next, func() { + i.mu.Lock() + defer i.mu.Unlock() + next := i.clock.Until(i.activity.Add(10*time.Minute)) + if next == 0 { + i.timeoutLocked() + return + } + t.Reset(next) + }) +} +``` + +The actual contents of `timeoutLocked()` doesn't matter for this example, and assume there are other +functions that record the latest `activity`. + +We found that some time testing libraries hold a lock on the mock clock while calling the function +passed to `AfterFunc`, resulting in a deadlock if you made clock calls from within. + +Others allow this sort of thing, but don't have the flexibility to test edge cases. There is a +subtle bug in our `Start()` function. The timer may pop a little late, and/or some measurable real +time may elapse before `Until()` gets called inside the `AfterFunc`. If there hasn't been activity, +`next` might be negative. + +To test this in Quartz, we'll use a trap. We only want to trap the inner `Until()` call, not the +initial one, so to make testing easier we can "tag" the call we want. Like this: + +```go +func (i *InactivityTimer) Start() { + i.mu.Lock() + defer i.mu.Unlock() + next := i.clock.Until(i.activity.Add(10*time.Minute)) + t := i.clock.AfterFunc(next, func() { + i.mu.Lock() + defer i.mu.Unlock() + next := i.clock.Until(i.activity.Add(10*time.Minute), "inner") + if next == 0 { + i.timeoutLocked() + return + } + t.Reset(next) + }) +} +``` + +All Quartz `Clock` functions, and functions on returned timers and tickers support zero or more +string tags that allow traps to match on them. + +```go +func TestInactivityTimer_Late(t *testing.T) { + // set a timeout on the test itself, so that if Wait functions get blocked, we don't have to + // wait for the default test timeout of 10 minutes. + ctx, cancel := context.WithTimeout(10*time.Second) + defer cancel() + mClock := quartz.NewMock(t) + trap := mClock.Trap.Until("inner") + defer trap.Close() + + it := &InactivityTimer{ + activity: mClock.Now(), + clock: mClock, + } + it.Start() + + // Trigger the AfterFunc + w := mClock.Advance(10*time.Minute) + c := trap.Wait(ctx) + // Advance the clock a few ms to simulate a busy system + mClock.Advance(3*time.Millisecond) + c.Release() // Until() returns + w.MustWait(ctx) // Wait for the AfterFunc to wrap up + + // Assert that the timeoutLocked() function was called +} +``` + +This test case will fail with our bugged implementation, since the triggered AfterFunc won't call +`timeoutLocked()` and instead will reset the timer with a negative number. The fix is easy, use +`next <= 0` as the comparison. diff --git a/vendor/github.com/coder/quartz/clock.go b/vendor/github.com/coder/quartz/clock.go new file mode 100644 index 000000000000..729edfa5623d --- /dev/null +++ b/vendor/github.com/coder/quartz/clock.go @@ -0,0 +1,43 @@ +// Package quartz is a library for testing time related code. It exports an interface Clock that +// mimics the standard library time package functions. In production, an implementation that calls +// thru to the standard library is used. In testing, a Mock clock is used to precisely control and +// intercept time functions. +package quartz + +import ( + "context" + "time" +) + +type Clock interface { + // NewTicker returns a new Ticker containing a channel that will send the current time on the + // channel after each tick. The period of the ticks is specified by the duration argument. The + // ticker will adjust the time interval or drop ticks to make up for slow receivers. The + // duration d must be greater than zero; if not, NewTicker will panic. Stop the ticker to + // release associated resources. + NewTicker(d time.Duration, tags ...string) *Ticker + // TickerFunc is a convenience function that calls f on the interval d until either the given + // context expires or f returns an error. Callers may call Wait() on the returned Waiter to + // wait until this happens and obtain the error. The duration d must be greater than zero; if + // not, TickerFunc will panic. + TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter + // NewTimer creates a new Timer that will send the current time on its channel after at least + // duration d. + NewTimer(d time.Duration, tags ...string) *Timer + // AfterFunc waits for the duration to elapse and then calls f in its own goroutine. It returns + // a Timer that can be used to cancel the call using its Stop method. The returned Timer's C + // field is not used and will be nil. + AfterFunc(d time.Duration, f func(), tags ...string) *Timer + + // Now returns the current local time. + Now(tags ...string) time.Time + // Since returns the time elapsed since t. It is shorthand for Clock.Now().Sub(t). + Since(t time.Time, tags ...string) time.Duration + // Until returns the duration until t. It is shorthand for t.Sub(Clock.Now()). + Until(t time.Time, tags ...string) time.Duration +} + +// Waiter can be waited on for an error. +type Waiter interface { + Wait(tags ...string) error +} diff --git a/vendor/github.com/coder/quartz/mock.go b/vendor/github.com/coder/quartz/mock.go new file mode 100644 index 000000000000..5f97e67c4e00 --- /dev/null +++ b/vendor/github.com/coder/quartz/mock.go @@ -0,0 +1,646 @@ +package quartz + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" + "testing" + "time" +) + +// Mock is the testing implementation of Clock. It tracks a time that monotonically increases +// during a test, triggering any timers or tickers automatically. +type Mock struct { + tb testing.TB + mu sync.Mutex + + // cur is the current time + cur time.Time + + all []event + nextTime time.Time + nextEvents []event + traps []*Trap +} + +type event interface { + next() time.Time + fire(t time.Time) +} + +func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter { + if d <= 0 { + panic("TickerFunc called with negative or zero duration") + } + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionTickerFunc, tags, withDuration(d)) + m.matchCallLocked(c) + defer close(c.complete) + t := &mockTickerFunc{ + ctx: ctx, + d: d, + f: f, + nxt: m.cur.Add(d), + mock: m, + cond: sync.NewCond(&m.mu), + } + m.all = append(m.all, t) + m.recomputeNextLocked() + go t.waitForCtx() + return t +} + +func (m *Mock) NewTicker(d time.Duration, tags ...string) *Ticker { + if d <= 0 { + panic("NewTicker called with negative or zero duration") + } + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionNewTicker, tags, withDuration(d)) + m.matchCallLocked(c) + defer close(c.complete) + // 1 element buffer follows standard library implementation + ticks := make(chan time.Time, 1) + t := &Ticker{ + C: ticks, + c: ticks, + d: d, + nxt: m.cur.Add(d), + mock: m, + } + m.addEventLocked(t) + return t +} + +func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionNewTimer, tags, withDuration(d)) + defer close(c.complete) + m.matchCallLocked(c) + ch := make(chan time.Time, 1) + t := &Timer{ + C: ch, + c: ch, + nxt: m.cur.Add(d), + mock: m, + } + if d <= 0 { + // zero or negative duration timer means we should immediately fire + // it, rather than add it. + go t.fire(t.mock.cur) + return t + } + m.addEventLocked(t) + return t +} + +func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionAfterFunc, tags, withDuration(d)) + defer close(c.complete) + m.matchCallLocked(c) + t := &Timer{ + nxt: m.cur.Add(d), + fn: f, + mock: m, + } + if d <= 0 { + // zero or negative duration timer means we should immediately fire + // it, rather than add it. + go t.fire(t.mock.cur) + return t + } + m.addEventLocked(t) + return t +} + +func (m *Mock) Now(tags ...string) time.Time { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionNow, tags) + defer close(c.complete) + m.matchCallLocked(c) + return m.cur +} + +func (m *Mock) Since(t time.Time, tags ...string) time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionSince, tags, withTime(t)) + defer close(c.complete) + m.matchCallLocked(c) + return m.cur.Sub(t) +} + +func (m *Mock) Until(t time.Time, tags ...string) time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionUntil, tags, withTime(t)) + defer close(c.complete) + m.matchCallLocked(c) + return t.Sub(m.cur) +} + +func (m *Mock) addEventLocked(e event) { + m.all = append(m.all, e) + m.recomputeNextLocked() +} + +func (m *Mock) recomputeNextLocked() { + var best time.Time + var events []event + for _, e := range m.all { + if best.IsZero() || e.next().Before(best) { + best = e.next() + events = []event{e} + continue + } + if e.next().Equal(best) { + events = append(events, e) + continue + } + } + m.nextTime = best + m.nextEvents = events +} + +func (m *Mock) removeTimer(t *Timer) { + m.mu.Lock() + defer m.mu.Unlock() + m.removeTimerLocked(t) +} + +func (m *Mock) removeTimerLocked(t *Timer) { + t.stopped = true + m.removeEventLocked(t) +} + +func (m *Mock) removeEventLocked(e event) { + defer m.recomputeNextLocked() + for i := range m.all { + if m.all[i] == e { + m.all = append(m.all[:i], m.all[i+1:]...) + return + } + } +} + +func (m *Mock) matchCallLocked(c *Call) { + var traps []*Trap + for _, t := range m.traps { + if t.matches(c) { + traps = append(traps, t) + } + } + if len(traps) == 0 { + return + } + c.releases.Add(len(traps)) + m.mu.Unlock() + for _, t := range traps { + go t.catch(c) + } + c.releases.Wait() + m.mu.Lock() +} + +// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for ticks and timers +// to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the +// functions to return. For other ticks & timers, it just waits for the tick to be delivered to +// the channel. +// +// If multiple timers or tickers trigger simultaneously, they are all run on separate +// go routines. +type AdvanceWaiter struct { + tb testing.TB + ch chan struct{} +} + +// Wait for all timers and ticks to complete, or until context expires. +func (w AdvanceWaiter) Wait(ctx context.Context) error { + select { + case <-w.ch: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// MustWait waits for all timers and ticks to complete, and fails the test immediately if the +// context completes first. MustWait must be called from the goroutine running the test or +// benchmark, similar to `t.FailNow()`. +func (w AdvanceWaiter) MustWait(ctx context.Context) { + w.tb.Helper() + select { + case <-w.ch: + return + case <-ctx.Done(): + w.tb.Fatalf("context expired while waiting for clock to advance: %s", ctx.Err()) + } +} + +// Done returns a channel that is closed when all timers and ticks complete. +func (w AdvanceWaiter) Done() <-chan struct{} { + return w.ch +} + +// Advance moves the clock forward by d, triggering any timers or tickers. The returned value can +// be used to wait for all timers and ticks to complete. Advance sets the clock forward before +// returning, and can only advance up to the next timer or tick event. It will fail the test if you +// attempt to advance beyond. +// +// If you need to advance exactly to the next event, and don't know or don't wish to calculate it, +// consider AdvanceNext(). +func (m *Mock) Advance(d time.Duration) AdvanceWaiter { + m.tb.Helper() + w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + m.mu.Lock() + fin := m.cur.Add(d) + // nextTime.IsZero implies no events scheduled. + if m.nextTime.IsZero() || fin.Before(m.nextTime) { + m.cur = fin + m.mu.Unlock() + close(w.ch) + return w + } + if fin.After(m.nextTime) { + m.tb.Errorf(fmt.Sprintf("cannot advance %s which is beyond next timer/ticker event in %s", + d.String(), m.nextTime.Sub(m.cur))) + m.mu.Unlock() + close(w.ch) + return w + } + + m.cur = m.nextTime + go m.advanceLocked(w) + return w +} + +func (m *Mock) advanceLocked(w AdvanceWaiter) { + defer close(w.ch) + wg := sync.WaitGroup{} + for i := range m.nextEvents { + e := m.nextEvents[i] + t := m.cur + wg.Add(1) + go func() { + e.fire(t) + wg.Done() + }() + } + // release the lock and let the events resolve. This allows them to call back into the + // Mock to query the time or set new timers. Each event should remove or reschedule + // itself from nextEvents. + m.mu.Unlock() + wg.Wait() +} + +// Set the time to t. If the time is after the current mocked time, then this is equivalent to +// Advance() with the difference. You may only Set the time earlier than the current time before +// starting tickers and timers (e.g. at the start of your test case). +func (m *Mock) Set(t time.Time) AdvanceWaiter { + m.tb.Helper() + w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + m.mu.Lock() + if t.Before(m.cur) { + defer close(w.ch) + defer m.mu.Unlock() + // past + if !m.nextTime.IsZero() { + m.tb.Error("Set mock clock to the past after timers/tickers started") + } + m.cur = t + return w + } + // future + // nextTime.IsZero implies no events scheduled. + if m.nextTime.IsZero() || t.Before(m.nextTime) { + defer close(w.ch) + defer m.mu.Unlock() + m.cur = t + return w + } + if t.After(m.nextTime) { + defer close(w.ch) + defer m.mu.Unlock() + m.tb.Errorf("cannot Set time to %s which is beyond next timer/ticker event at %s", + t.String(), m.nextTime) + return w + } + + m.cur = m.nextTime + go m.advanceLocked(w) + return w +} + +// AdvanceNext advances the clock to the next timer or tick event. It fails the test if there are +// none scheduled. It returns the duration the clock was advanced and a waiter that can be used to +// wait for the timer/tick event(s) to finish. +func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { + m.mu.Lock() + m.tb.Helper() + w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + if m.nextTime.IsZero() { + defer close(w.ch) + defer m.mu.Unlock() + m.tb.Error("cannot AdvanceNext because there are no timers or tickers running") + } + d := m.nextTime.Sub(m.cur) + m.cur = m.nextTime + go m.advanceLocked(w) + return d, w +} + +// Peek returns the duration until the next ticker or timer event and the value +// true, or, if there are no running tickers or timers, it returns zero and +// false. +func (m *Mock) Peek() (d time.Duration, ok bool) { + m.mu.Lock() + defer m.mu.Unlock() + if m.nextTime.IsZero() { + return 0, false + } + return m.nextTime.Sub(m.cur), true +} + +// Trapper allows the creation of Traps +type Trapper struct { + // mock is the underlying Mock. This is a thin wrapper around Mock so that + // we can have our interface look like mClock.Trap().NewTimer("foo") + mock *Mock +} + +func (t Trapper) NewTimer(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionNewTimer, tags) +} + +func (t Trapper) AfterFunc(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionAfterFunc, tags) +} + +func (t Trapper) TimerStop(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTimerStop, tags) +} + +func (t Trapper) TimerReset(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTimerReset, tags) +} + +func (t Trapper) TickerFunc(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTickerFunc, tags) +} + +func (t Trapper) TickerFuncWait(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTickerFuncWait, tags) +} + +func (t Trapper) NewTicker(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionNewTicker, tags) +} + +func (t Trapper) TickerStop(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTickerStop, tags) +} + +func (t Trapper) TickerReset(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionTickerReset, tags) +} + +func (t Trapper) Now(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionNow, tags) +} + +func (t Trapper) Since(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionSince, tags) +} + +func (t Trapper) Until(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionUntil, tags) +} + +func (m *Mock) Trap() Trapper { + return Trapper{m} +} + +func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap { + m.mu.Lock() + defer m.mu.Unlock() + tr := &Trap{ + fn: fn, + tags: tags, + mock: m, + calls: make(chan *Call), + done: make(chan struct{}), + } + m.traps = append(m.traps, tr) + return tr +} + +// NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024. +// You may re-set the time earlier than this, but only before timers or tickers +// are created. +func NewMock(tb testing.TB) *Mock { + cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") + if err != nil { + panic(err) + } + return &Mock{ + tb: tb, + cur: cur, + } +} + +var _ Clock = &Mock{} + +type mockTickerFunc struct { + ctx context.Context + d time.Duration + f func() error + nxt time.Time + mock *Mock + + // cond is a condition Locked on the main Mock.mu + cond *sync.Cond + // done is true when the ticker exits + done bool + // err holds the error when the ticker exits + err error +} + +func (m *mockTickerFunc) next() time.Time { + return m.nxt +} + +func (m *mockTickerFunc) fire(_ time.Time) { + m.mock.mu.Lock() + defer m.mock.mu.Unlock() + if m.done { + return + } + m.nxt = m.nxt.Add(m.d) + m.mock.recomputeNextLocked() + + m.mock.mu.Unlock() + err := m.f() + m.mock.mu.Lock() + if err != nil { + m.exitLocked(err) + } +} + +func (m *mockTickerFunc) exitLocked(err error) { + if m.done { + return + } + m.done = true + m.err = err + m.mock.removeEventLocked(m) + m.cond.Broadcast() +} + +func (m *mockTickerFunc) waitForCtx() { + <-m.ctx.Done() + m.mock.mu.Lock() + defer m.mock.mu.Unlock() + m.exitLocked(m.ctx.Err()) +} + +func (m *mockTickerFunc) Wait(tags ...string) error { + m.mock.mu.Lock() + defer m.mock.mu.Unlock() + c := newCall(clockFunctionTickerFuncWait, tags) + m.mock.matchCallLocked(c) + defer close(c.complete) + for !m.done { + m.cond.Wait() + } + return m.err +} + +var _ Waiter = &mockTickerFunc{} + +type clockFunction int + +const ( + clockFunctionNewTimer clockFunction = iota + clockFunctionAfterFunc + clockFunctionTimerStop + clockFunctionTimerReset + clockFunctionTickerFunc + clockFunctionTickerFuncWait + clockFunctionNewTicker + clockFunctionTickerReset + clockFunctionTickerStop + clockFunctionNow + clockFunctionSince + clockFunctionUntil +) + +type callArg func(c *Call) + +type Call struct { + Time time.Time + Duration time.Duration + Tags []string + + fn clockFunction + releases sync.WaitGroup + complete chan struct{} +} + +func (c *Call) Release() { + c.releases.Done() + <-c.complete +} + +func withTime(t time.Time) callArg { + return func(c *Call) { + c.Time = t + } +} + +func withDuration(d time.Duration) callArg { + return func(c *Call) { + c.Duration = d + } +} + +func newCall(fn clockFunction, tags []string, args ...callArg) *Call { + c := &Call{ + fn: fn, + Tags: tags, + complete: make(chan struct{}), + } + for _, a := range args { + a(c) + } + return c +} + +type Trap struct { + fn clockFunction + tags []string + mock *Mock + calls chan *Call + done chan struct{} +} + +func (t *Trap) catch(c *Call) { + select { + case t.calls <- c: + case <-t.done: + c.Release() + } +} + +func (t *Trap) matches(c *Call) bool { + if t.fn != c.fn { + return false + } + for _, tag := range t.tags { + if !slices.Contains(c.Tags, tag) { + return false + } + } + return true +} + +func (t *Trap) Close() { + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + for i, tr := range t.mock.traps { + if t == tr { + t.mock.traps = append(t.mock.traps[:i], t.mock.traps[i+1:]...) + } + } + close(t.done) +} + +var ErrTrapClosed = errors.New("trap closed") + +func (t *Trap) Wait(ctx context.Context) (*Call, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-t.done: + return nil, ErrTrapClosed + case c := <-t.calls: + return c, nil + } +} + +// MustWait calls Wait() and then if there is an error, immediately fails the +// test via tb.Fatalf() +func (t *Trap) MustWait(ctx context.Context) *Call { + t.mock.tb.Helper() + c, err := t.Wait(ctx) + if err != nil { + t.mock.tb.Fatalf("context expired while waiting for trap: %s", err.Error()) + } + return c +} diff --git a/vendor/github.com/coder/quartz/real.go b/vendor/github.com/coder/quartz/real.go new file mode 100644 index 000000000000..f39fb1163e82 --- /dev/null +++ b/vendor/github.com/coder/quartz/real.go @@ -0,0 +1,80 @@ +package quartz + +import ( + "context" + "time" +) + +type realClock struct{} + +func NewReal() Clock { + return realClock{} +} + +func (realClock) NewTicker(d time.Duration, _ ...string) *Ticker { + tkr := time.NewTicker(d) + return &Ticker{ticker: tkr, C: tkr.C} +} + +func (realClock) TickerFunc(ctx context.Context, d time.Duration, f func() error, _ ...string) Waiter { + ct := &realContextTicker{ + ctx: ctx, + tkr: time.NewTicker(d), + f: f, + err: make(chan error, 1), + } + go ct.run() + return ct +} + +type realContextTicker struct { + ctx context.Context + tkr *time.Ticker + f func() error + err chan error +} + +func (t *realContextTicker) Wait(_ ...string) error { + return <-t.err +} + +func (t *realContextTicker) run() { + defer t.tkr.Stop() + for { + select { + case <-t.ctx.Done(): + t.err <- t.ctx.Err() + return + case <-t.tkr.C: + err := t.f() + if err != nil { + t.err <- err + return + } + } + } +} + +func (realClock) NewTimer(d time.Duration, _ ...string) *Timer { + rt := time.NewTimer(d) + return &Timer{C: rt.C, timer: rt} +} + +func (realClock) AfterFunc(d time.Duration, f func(), _ ...string) *Timer { + rt := time.AfterFunc(d, f) + return &Timer{C: rt.C, timer: rt} +} + +func (realClock) Now(_ ...string) time.Time { + return time.Now() +} + +func (realClock) Since(t time.Time, _ ...string) time.Duration { + return time.Since(t) +} + +func (realClock) Until(t time.Time, _ ...string) time.Duration { + return time.Until(t) +} + +var _ Clock = realClock{} diff --git a/vendor/github.com/coder/quartz/ticker.go b/vendor/github.com/coder/quartz/ticker.go new file mode 100644 index 000000000000..5506750d5d1f --- /dev/null +++ b/vendor/github.com/coder/quartz/ticker.go @@ -0,0 +1,75 @@ +package quartz + +import "time" + +// A Ticker holds a channel that delivers “ticks” of a clock at intervals. +type Ticker struct { + C <-chan time.Time + //nolint: revive + c chan time.Time + ticker *time.Ticker // realtime impl, if set + d time.Duration // period, if set + nxt time.Time // next tick time + mock *Mock // mock clock, if set + stopped bool // true if the ticker is not running +} + +func (t *Ticker) fire(tt time.Time) { + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + if t.stopped { + return + } + for !t.nxt.After(t.mock.cur) { + t.nxt = t.nxt.Add(t.d) + } + t.mock.recomputeNextLocked() + select { + case t.c <- tt: + default: + } +} + +func (t *Ticker) next() time.Time { + return t.nxt +} + +// Stop turns off a ticker. After Stop, no more ticks will be sent. Stop does +// not close the channel, to prevent a concurrent goroutine reading from the +// channel from seeing an erroneous "tick". +func (t *Ticker) Stop(tags ...string) { + if t.ticker != nil { + t.ticker.Stop() + return + } + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + c := newCall(clockFunctionTickerStop, tags) + t.mock.matchCallLocked(c) + defer close(c.complete) + t.mock.removeEventLocked(t) + t.stopped = true +} + +// Reset stops a ticker and resets its period to the specified duration. The +// next tick will arrive after the new period elapses. The duration d must be +// greater than zero; if not, Reset will panic. +func (t *Ticker) Reset(d time.Duration, tags ...string) { + if t.ticker != nil { + t.ticker.Reset(d) + return + } + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + c := newCall(clockFunctionTickerReset, tags, withDuration(d)) + t.mock.matchCallLocked(c) + defer close(c.complete) + t.nxt = t.mock.cur.Add(d) + t.d = d + if t.stopped { + t.stopped = false + t.mock.addEventLocked(t) + } else { + t.mock.recomputeNextLocked() + } +} diff --git a/vendor/github.com/coder/quartz/timer.go b/vendor/github.com/coder/quartz/timer.go new file mode 100644 index 000000000000..8d928ac6f609 --- /dev/null +++ b/vendor/github.com/coder/quartz/timer.go @@ -0,0 +1,81 @@ +package quartz + +import "time" + +// The Timer type represents a single event. When the Timer expires, the current time will be sent +// on C, unless the Timer was created by AfterFunc. A Timer must be created with NewTimer or +// AfterFunc. +type Timer struct { + C <-chan time.Time + //nolint: revive + c chan time.Time + timer *time.Timer // realtime impl, if set + nxt time.Time // next tick time + mock *Mock // mock clock, if set + fn func() // AfterFunc function, if set + stopped bool // True if stopped, false if running +} + +func (t *Timer) fire(tt time.Time) { + t.mock.removeTimer(t) + if t.fn != nil { + t.fn() + } else { + t.c <- tt + } +} + +func (t *Timer) next() time.Time { + return t.nxt +} + +// Stop prevents the Timer from firing. It returns true if the call stops the timer, false if the +// timer has already expired or been stopped. Stop does not close the channel, to prevent a read +// from the channel succeeding incorrectly. +// +// See https://pkg.go.dev/time#Timer.Stop for more information. +func (t *Timer) Stop(tags ...string) bool { + if t.timer != nil { + return t.timer.Stop() + } + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + c := newCall(clockFunctionTimerStop, tags) + t.mock.matchCallLocked(c) + defer close(c.complete) + result := !t.stopped + t.mock.removeTimerLocked(t) + return result +} + +// Reset changes the timer to expire after duration d. It returns true if the timer had been active, +// false if the timer had expired or been stopped. +// +// See https://pkg.go.dev/time#Timer.Reset for more information. +func (t *Timer) Reset(d time.Duration, tags ...string) bool { + if t.timer != nil { + return t.timer.Reset(d) + } + t.mock.mu.Lock() + defer t.mock.mu.Unlock() + c := newCall(clockFunctionTimerReset, tags, withDuration(d)) + t.mock.matchCallLocked(c) + defer close(c.complete) + result := !t.stopped + select { + case <-t.c: + default: + } + if d <= 0 { + // zero or negative duration timer means we should immediately re-fire + // it, rather than remove and re-add it. + t.stopped = false + go t.fire(t.mock.cur) + return result + } + t.mock.removeTimerLocked(t) + t.stopped = false + t.nxt = t.mock.cur.Add(d) + t.mock.addEventLocked(t) + return result +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 3987a73ef672..e513c15d0aae 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -499,6 +499,9 @@ github.com/cncf/xds/go/xds/data/orca/v3 github.com/cncf/xds/go/xds/service/orca/v3 github.com/cncf/xds/go/xds/type/matcher/v3 github.com/cncf/xds/go/xds/type/v3 +# github.com/coder/quartz v0.1.0 +## explicit; go 1.21.8 +github.com/coder/quartz # github.com/containerd/fifo v1.0.0 ## explicit; go 1.13 github.com/containerd/fifo