|
| 1 | ++++ |
| 2 | +hook = "Using a coalescable parameter to stub time as necessary in tests, but otherwise use the shared database clock across all operations." |
| 3 | +# image = "" |
| 4 | +published_at = 2025-06-29T10:48:16-07:00 |
| 5 | +title = "Occasionally injected clocks in Postgres" |
| 6 | ++++ |
| 7 | + |
| 8 | +In a standard app deployment that's scaled horizontally across many nodes, we can expect the clocks to be a little askew across the fleet. It's generally not a huge problem these days because our [use of NTP](https://en.wikipedia.org/wiki/Network_Time_Protocol) is so good and so widespread, but minor drift is still present. |
| 9 | + |
| 10 | +Where a single source of time authority is desired, a nice trick is to use the database. A single database is shared across all deployed nodes, so by using the database's `now()` function instead of `time.Now()` in code, we can expect perfect consistency across all created records. |
| 11 | + |
| 12 | +But a downside of this approach is that it makes time hard to stub because Postgres' time is hard to stub. Stubbing time is often a necessity in tests and not being able to do so is a deal breaker. |
| 13 | + |
| 14 | +We've been using a hybrid approach with some success. A call to `coalesce` prefers an injected timestamp if there is one, but falls back on `now()` most of the time (including in production) to share a clock. |
| 15 | + |
| 16 | +## Step 1: SQL + sqlc |
| 17 | + |
| 18 | +Here's a sample query showing the `coalesce` in action. `sqlc.narg` defines a parameter as nullable. |
| 19 | + |
| 20 | +``` sql |
| 21 | +-- name: QueuePause :execrows |
| 22 | +UPDATE /* TEMPLATE: schema */river_queue |
| 23 | +SET paused_at = CASE |
| 24 | + WHEN paused_at IS NULL THEN coalesce( |
| 25 | + sqlc.narg('now')::timestamptz, |
| 26 | + now() |
| 27 | + ) |
| 28 | + ELSE paused_at |
| 29 | + END |
| 30 | +WHERE name = @name; |
| 31 | +``` |
| 32 | + |
| 33 | +In `sqlc.yaml`, tell sqlc to emit nullable timestamps as `*time.Time` pointers: |
| 34 | + |
| 35 | +``` yaml |
| 36 | +version: "2" |
| 37 | +sql: |
| 38 | + - engine: "postgresql" |
| 39 | + queries: ... |
| 40 | + schema: ... |
| 41 | + gen: |
| 42 | + go: |
| 43 | + overrides: |
| 44 | + - db_type: "timestamptz" |
| 45 | + go_type: |
| 46 | + type: "time.Time" |
| 47 | + pointer: true |
| 48 | + nullable: true |
| 49 | +``` |
| 50 | +
|
| 51 | +Which generates this code: |
| 52 | +
|
| 53 | +``` go |
| 54 | +const queuePause = `-- name: QueuePause :execrows |
| 55 | +UPDATE /* TEMPLATE: schema */river_queue |
| 56 | +SET |
| 57 | + paused_at = CASE WHEN paused_at IS NULL THEN coalesce($1::timestamptz, now()) ELSE paused_at END |
| 58 | +WHERE CASE WHEN $2::text = '*' THEN true ELSE name = $2 END |
| 59 | +` |
| 60 | +
|
| 61 | +type QueuePauseParams struct { |
| 62 | + Now *time.Time |
| 63 | + Name string |
| 64 | +} |
| 65 | +
|
| 66 | +func (q *Queries) QueuePause(ctx context.Context, db DBTX, arg *QueuePauseParams) (int64, error) { |
| 67 | + result, err := db.Exec(ctx, queuePause, arg.Now, arg.Name) |
| 68 | + if err != nil { |
| 69 | + return 0, err |
| 70 | + } |
| 71 | + return result.RowsAffected(), nil |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +## Step 2: Stubabble time generator |
| 76 | + |
| 77 | +Working in Go, define a `TimeGenerator` interface: |
| 78 | + |
| 79 | +* When unstubbed, it returns the current time from `NowUTC()` or `nil` from `NowUTCOrNil()`. |
| 80 | +* When stubbed, it returns the stubbed time from `NowUTC()` or a pointer version of the same from `NowUTCOrNil()`. |
| 81 | + |
| 82 | +``` go |
| 83 | +// TimeGenerator generates a current time in UTC. In test |
| 84 | +// environments it's implemented by TimeStub which lets the |
| 85 | +// current time be stubbed. Otherwise, it's implemented as |
| 86 | +// UnstubbableTimeGenerator which doesn't allow stubbing. |
| 87 | +type TimeGenerator interface { |
| 88 | + // NowUTC returns the current time. This may be a stubbed |
| 89 | + // time if the time has been actively stubbed in a test. |
| 90 | + NowUTC() time.Time |
| 91 | +
|
| 92 | + // NowUTCOrNil returns if the currently stubbed time _if_ |
| 93 | + // the current time is stubbed, and returns nil otherwise. |
| 94 | + // This is generally useful in cases where a component may |
| 95 | + // want to use a stubbed time if the time is stubbed, but |
| 96 | + // to fall back to a database time default otherwise. |
| 97 | + NowUTCOrNil() *time.Time |
| 98 | +} |
| 99 | +``` |
| 100 | + |
| 101 | +A stubbable implementation for tests: |
| 102 | + |
| 103 | +``` go |
| 104 | +type TimeStub struct { |
| 105 | + nowUTC *time.Time |
| 106 | +} |
| 107 | +
|
| 108 | +func (t *TimeStub) NowUTC() time.Time { |
| 109 | + if t.nowUTC == nil { |
| 110 | + return time.Now().UTC() |
| 111 | + } |
| 112 | +
|
| 113 | + return *t.nowUTC |
| 114 | +} |
| 115 | +
|
| 116 | +func (t *TimeStub) NowUTCOrNil() *time.Time { |
| 117 | + return t.nowUTC |
| 118 | +} |
| 119 | +
|
| 120 | +func (t *TimeStub) StubNowUTC(nowUTC time.Time) time.Time { |
| 121 | + t.nowUTC = &nowUTC |
| 122 | + return nowUTC |
| 123 | +} |
| 124 | +``` |
| 125 | + |
| 126 | +An unstubbable time generator for production: |
| 127 | + |
| 128 | +``` go |
| 129 | +type UnstubbableTimeGenerator struct{} |
| 130 | +
|
| 131 | +func (g *UnstubbableTimeGenerator) NowUTC() time.Time { return time.Now() } |
| 132 | +func (g *UnstubbableTimeGenerator) NowUTCOrNil() *time.Time { return nil } |
| 133 | +
|
| 134 | +func (g *UnstubbableTimeGenerator) StubNowUTC(nowUTC time.Time) time.Time { |
| 135 | + panic("time not stubbable outside tests") |
| 136 | +} |
| 137 | +``` |
| 138 | + |
| 139 | +### Step 3: Distributing a shared time generator |
| 140 | + |
| 141 | +The next key aspect is that all code needs to share a single instance of `TimeGenerator` so that when it's stubbed from a test, all services and subservices get the same stubbed value. |
| 142 | + |
| 143 | +We put a `TimeGenerator` on a base service archetype that's automatically injected from top-level services to subservices: |
| 144 | + |
| 145 | +``` go |
| 146 | +func (c *Client[TTx]) QueuePauseTx(ctx context.Context, tx TTx, name string, opts *QueuePauseOpts) error { |
| 147 | + executorTx := c.driver.UnwrapExecutor(tx) |
| 148 | +
|
| 149 | + if err := executorTx.QueuePause(ctx, &riverdriver.QueuePauseParams{ |
| 150 | + Name: name, |
| 151 | + Now: c.baseService.Time.NowUTCOrNil(), // <-- accessed here |
| 152 | + Schema: c.config.Schema, |
| 153 | + }); err != nil { |
| 154 | + return err |
| 155 | + } |
| 156 | +``` |
| 157 | + |
| 158 | +By default, it's instantiated as `UnstubbableTimeGenerator`. From tests, it's a `TimeStub`: |
| 159 | + |
| 160 | +``` go |
| 161 | +func BaseServiceArchetype(tb testing.TB) *baseservice.Archetype { |
| 162 | + tb.Helper() |
| 163 | +
|
| 164 | + return &baseservice.Archetype{ |
| 165 | + Logger: Logger(tb), |
| 166 | + Time: &TimeStub{}, |
| 167 | + } |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +In a test, time is stubbed like: |
| 172 | + |
| 173 | +``` go |
| 174 | +stubbedNow := client.baseService.Time.StubNowUTC(time.Now().UTC()) |
| 175 | +``` |
| 176 | + |
| 177 | +## Loose conviction |
| 178 | + |
| 179 | +Consider this one a loose recommendation. It's useful in some situations where timestamp consistency is critically important, but not in others where it isn't. Server clocks tend to be pretty good nowadays, and it's a lot of code to avoid a few tens of microseconds worth of drift. |
| 180 | + |
| 181 | +Also, consider that there might be a downside to using the database clock. In SQL, `CURRENT_TIMESTAMP` and `now()` in Postgres represent the current time _at the start of the current transaction_ rather than the current time. This might be a benefit as all records created during a transaction are assigned the same created time, but it's just as often undesirable because depending on the duration of the transaction, timestamps can be wildly unrepresentative of when things actually happened. |
0 commit comments