Skip to content

Commit 913fd2b

Browse files
committed
Publish fragment on occasionally injected clocks in Postgres
1 parent 733ba8d commit 913fd2b

2 files changed

Lines changed: 187 additions & 0 deletions

File tree

content/atoms/_meta.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
#
1111
################################################################################
1212

13+
[[atoms]]
14+
published_at = 2025-06-29T10:48:16-07:00
15+
description = """\
16+
Published fragment [Occasionally injected clocks in Postgres](/fragments/postgres-clocks), on using a coalescable parameter to stub time as necessary in tests, but otherwise use the shared database clock across all operations.
17+
"""
18+
1319
[[atoms]]
1420
published_at = 2025-06-24T07:10:46+02:00
1521
description = """\
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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

Comments
 (0)