go / wakeups
A page that shows the state of a system is stale the moment the browser draws it. A script that pushed a commit and wants the test result has to guess how long to sleep. A worker that wants a job has to ask again and again.
I solve all three with one signal. Postgres says "something changed". Go fans that signal out to whoever is waiting. Each waiter re-reads what it cares about. Nothing polls on a timer, and a reader learns of a write within a second.
The signal carries no data. That is what keeps it cheap. This article covers the server side. web / live regions covers what a page does when the signal arrives.
One signal
Every state a reader cares about is a row in Postgres: a job, a
result, a comment, a deploy. A trigger on each table fires NOTIFY
after any write:
CREATE FUNCTION notify_wakeup ()
RETURNS TRIGGER
AS $$
BEGIN
NOTIFY wakeup;
RETURN NULL;
END;
$$
LANGUAGE plpgsql;
CREATE TRIGGER comment_wakeup
AFTER INSERT OR UPDATE OR DELETE ON comment
EXECUTE PROCEDURE notify_wakeup ();
The trigger is statement-level, so a bulk write fires it once.
The notification has no payload. A payload is a second schema, and it drifts from the tables it describes. A reader who gets the bare signal reads the rows it needs, and the rows are the truth. A spurious wakeup costs one small query, so I do not try to suppress them.
The trigger fires from Postgres rather than from the Go code that wrote
the row. Every write path then fires it: the handler, a psql
session, a migration, and code I write next year and forget about.
Postgres also delivers the notification at commit, so a reader who
wakes sees the row.
One listener
A pgxpool connection cannot receive notifications, so the server
opens one plain connection for LISTEN:
func (s *server) listen(ctx context.Context) error {
conn, err := pgx.Connect(ctx, s.dbURL)
if err != nil {
return fmt.Errorf("listener connect: %w", err)
}
defer conn.Close(context.Background())
if _, err := conn.Exec(ctx, "LISTEN wakeup"); err != nil {
return fmt.Errorf("listen: %w", err)
}
if err := s.loadState(ctx); err != nil {
return fmt.Errorf("load state: %w", err)
}
for {
if _, err := conn.WaitForNotification(ctx); err != nil {
return fmt.Errorf("wait notification: %w", err)
}
s.wake()
}
}
A loop around it reconnects after a failure, with a pause so a database that is down does not spin the loop. It loads the full state on every connect, before it waits. A notification sent during the disconnect is lost, and the reload covers it.
Fan-out
Inside the process, a subscriber is a channel with a buffer of one:
func (s *server) subscribe() chan struct{} {
ch := make(chan struct{}, 1)
s.subsMu.Lock()
s.subs[ch] = struct{}{}
s.subsMu.Unlock()
return ch
}
func (s *server) unsubscribe(ch chan struct{}) {
s.subsMu.Lock()
delete(s.subs, ch)
s.subsMu.Unlock()
}
func (s *server) wake() {
s.subsMu.Lock()
defer s.subsMu.Unlock()
for ch := range s.subs {
select {
case ch <- struct{}{}:
default:
}
}
}
The buffer of one and the non-blocking send do two jobs. A burst of writes coalesces into one pending wakeup, so a sweep of result rows wakes a subscriber once. A subscriber that is slow to read cannot block the listener, so one stuck request cannot stall every other waiter.
The subscriber gets no data from the channel. It re-reads.
Subscribe before the first read:
wake := s.subscribe()
defer s.unsubscribe(wake)
if settled := s.read(); settled {
return
}
<-wake
In the other order there is a gap between the read and the subscribe. A write that lands in the gap wakes nobody, and the waiter sleeps until the write after it.
A held request
A worker that wants a job sends the state it last saw. The server answers when the state differs, or when a timeout ends. The answer is the current state either way:
func (s *server) longPoll(ctx context.Context, old State) State {
ctx, cancel := context.WithTimeout(ctx, PollWait)
defer cancel()
c := make(chan State, 1)
go func() { c <- s.waitNewState(old) }()
select {
case cur := <-c:
return cur
case <-ctx.Done():
return old
}
}
The timeout answers with a state, not with an error. A request held open forever cannot be told from a server that stopped. A request that answers every 25 seconds with "no news" proves the server is up.
PollWait is one constant that both sides import. The client's HTTP
timeout is PollWait plus a margin. A client that gives up first
aborts every idle poll, throws the connection away, and logs a timeout
for a server that behaved.
Replacing sleep
A script that pushes a commit and then asks how the checks went has to guess how long to wait. A guess too long wastes time nobody needed. A guess too short reads a green answer about the commit before the one it pushed. No script gets the number right, because the number is the length of somebody's test suite.
The CLI asks the server to hold the request instead. The server subscribes, checks whether the head has settled, and waits for a wakeup or the deadline before it reads again:
func (s *server) waitForHead(ctx context.Context, c *change, sha string) (*change, error) {
wake := s.subscribe()
defer s.unsubscribe(wake)
deadline := time.NewTimer(PollWait)
defer deadline.Stop()
for {
settled, err := s.headSettled(ctx, c, sha)
if err != nil || settled {
return c, err
}
select {
case <-wake:
case <-deadline.C:
return c, nil
case <-ctx.Done():
return nil, ctx.Err()
}
if c, err = s.findChange(ctx, c.ID); err != nil {
return nil, err
}
}
}
The CLI loops on that request until the change settles or its own timeout runs out. An answer that is not the end still carries the checks that finished so far, so the CLI prints one line per check as it lands.
The sha is the commit the CLI stands on. A wait that reaches the
server before the push hook records the new head would read the old
head's checks: green, about the wrong code. The server treats a head
that is not sha yet as not settled. A sleep hid that mistake and
did not fix it.
The browser
The same subscription sits behind a Server-Sent Events endpoint. Each
wakeup writes an empty event named wake, and a comment line every
15 seconds keeps a proxy from closing an idle connection:
func (s *server) wakeStream(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-store, no-transform")
w.Header().Set("X-Accel-Buffering", "no")
flush := w.(http.Flusher).Flush
flush()
ch := s.subscribe()
defer s.unsubscribe(ch)
keepalive := time.NewTicker(15 * time.Second)
defer keepalive.Stop()
for {
select {
case <-ch:
io.WriteString(w, "event: wake\ndata:\n\n")
case <-keepalive.C:
io.WriteString(w, ": keepalive\n\n")
case <-req.Context().Done():
return
}
flush()
}
}
The event says nothing about what changed. The page decides what to re-read, which is the subject of web / live regions.
Fit
This fits a system whose state is small enough to re-read on every
wakeup, and whose writes all go through one Postgres database. The
subscribers live in process memory, and Postgres delivers each
notification to every listening connection, so a second server process
opens its own LISTEN and wakes its own subscribers. There is no
message broker and no schema for events. The cost is a query per
wakeup per waiter, which a small state makes cheap.
I use it in cibot, where the waiters are worker boxes,
cibot show --wait, and every open dashboard page.