Help us shape the future of Go! Take the 2026 Go Developer Survey

dbstick

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 5 Imported by: 0

README

A Go gopher spearing a database labeled Primary, beside one labeled Replica

dbstick

Read your own writes with a replica database, even on the next request.

Test Coverage Go Reference

A user submits a form and is redirected to the result. The read runs on a replica that has not caught up yet, so the user sees the old data.

dbstick fixes this. When a user writes, it stores their key in a shared store, such as Redis. For the next few seconds, that user's reads go to the primary. Other users keep reading from the replica.

It is a Go port of mpyw/laravel-cached-database-stickiness.

Install

go get github.com/mpyw/dbstick
go get github.com/mpyw/dbstick/redisstore

Then add the hook for your driver. The hook sees each write on the primary and records it. Each hook's README says how to set it up.

Driver Hook
pgx, or database/sql with pgx pgxstick
Any other database/sql driver sqlstick
GORM gormstick, plus one of the above

Usage

Make a Tracker, open the primary with your driver's hook, and give both handles to a Router. Each hook's README shows how to open the primary.

tracker := dbstick.NewTracker(
	store,
	auth.UserID, // func(ctx) string
)

// primary has the hook. replica does not.
db := dbstick.NewRouter(tracker, primary, replica)

http.ListenAndServe(
	":8080",
	tracker.Middleware(mux),
)

In a handler, pick the handle with ForWrite or ForRead:

// Returns the primary.
// Its hook records each write.
db.ForWrite()

// Returns the primary if this user wrote recently.
// Returns the replica otherwise.
db.ForRead(ctx)

Router returns the handles you gave it, and does not wrap them. So sqlc, go-jet, sqlx, bun and ent work as before:

q := sqlcdb.New(db.ForRead(ctx))
user, err := q.GetUser(ctx, id)

The key function reads the user from ctx when it is needed. So your auth middleware can run before or after tracker.Middleware. It returns "" for a guest. A guest still reads their own writes within one request, but not on the next one.

Transactions

In a transaction, a write makes the current request sticky at once. The next requests become sticky only when the transaction commits, so the TTL starts at the commit. A rollback drops the write.

A failed write is not recorded. A hook cannot see every write, such as a function called with SELECT. Each hook's README lists what it sees. For the rest, call tracker.MarkWritten(ctx) yourself.

Jobs

Middleware gives each request a scope. Outside a scope, every read goes to the replica and nothing is remembered. A job starts its own scope:

// Like a request: the replica until it writes.
ctx = tracker.NewContext(ctx)

// The primary from the start, for a job that
// must see the write that queued it.
ctx = tracker.NewStickyContext(ctx)

A gRPC server or another framework does the same with tracker.NewContext in its interceptor or middleware.

Options

tracker := dbstick.NewTracker(
	store,
	auth.UserID,
	dbstick.WithTTL(10*time.Second),
	dbstick.WithPrefix("main:"),
	dbstick.WithErrorHandler(logError),
	dbstick.WithPutInterval(time.Second),
)
Option Default
WithTTL 5s How long reads stay on the primary. Make it longer than the replica lag
WithPrefix "dbstick:" Prefix of the store keys. Use one per Tracker if they share a store
WithErrorHandler Ignore errors Called with each store error
WithPutInterval 0 Saves a key at most once per interval in one request. Use it if a request writes many times

With WithPutInterval, a key is saved for the TTL plus the interval. So reads stay on the primary for the full TTL after the last write.

A store error does not fail the request. If the lookup fails, the read goes to the primary. If saving fails, only the current request reads from the primary.

Stores

Store Use it for
redisstore Several servers that share a Redis
dbstick.NewMemoryStore() Tests and single-server apps. It does not work across servers

For another backend, implement Store:

type Store interface {
	Exists(
		ctx context.Context,
		key string,
	) (bool, error)
	Put(
		ctx context.Context,
		key string,
		ttl time.Duration,
	) error
}

Differences from the Laravel package

  • The Laravel package hooks Laravel's connections. Here a hook on the primary's driver does the same.
  • The Laravel package sends jobs to the primary by default. Here a job reads like a request, unless it uses NewStickyContext.
  • The key comes from a function of the context, not from a resolver class.

License

MIT

Documentation

Overview

Package dbstick sends reads to the primary database for a while after a write, so that a user reads what they just wrote, even on their next request. It is a Go port of mpyw/laravel-cached-database-stickiness.

A Tracker remembers writes, per key such as a user ID, in a Store that every app server shares. A Router picks the primary or the replica from what the Tracker remembers. It does not wrap the database, so it works with *sql.DB, *pgxpool.Pool, or any other handle.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type MemoryStore

type MemoryStore struct {
	// contains filtered or unexported fields
}

MemoryStore is a Store in the memory of one process. It suits tests, and a single app server. With several servers, use a shared Store such as the one in github.com/mpyw/dbstick/redisstore.

func NewMemoryStore

func NewMemoryStore() *MemoryStore

NewMemoryStore returns an empty MemoryStore.

func (*MemoryStore) Exists

func (s *MemoryStore) Exists(_ context.Context, key string) (bool, error)

Exists reports whether key was put and has not expired. It never fails.

func (*MemoryStore) Put

func (s *MemoryStore) Put(_ context.Context, key string, ttl time.Duration) error

Put stores key for ttl. It never fails. Once the store has grown to twice its size after the last sweep, Put drops every expired key, so keys that are never read again do not pile up.

type Router

type Router[DB any] struct {
	// contains filtered or unexported fields
}

Router picks the primary or the replica for a context. DB is any database handle, such as *sql.DB or *pgxpool.Pool. Router does not wrap it, so every library that takes the handle works as before.

Router only reads what the Tracker remembers. A write is recorded by a hook on the primary's driver, or by Tracker.MarkWritten.

Example
package main

import (
	"context"
	"fmt"

	"github.com/mpyw/dbstick"
)

type userKey struct{}

// withUser stands for an auth middleware.
func withUser(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, userKey{}, id)
}

func userID(ctx context.Context) string {
	id, _ := ctx.Value(userKey{}).(string)
	return id
}

func main() {
	tracker := dbstick.NewTracker(dbstick.NewMemoryStore(), userID)
	db := dbstick.NewRouter(tracker, "primary", "replica")

	ctx := withUser(tracker.NewContext(context.Background()), "alice")
	fmt.Println("before a write:", db.ForRead(ctx))
	_ = db.ForWrite()        // INSERT ...
	tracker.MarkWritten(ctx) // a driver hook does this
	fmt.Println("after a write:", db.ForRead(ctx))

	next := withUser(tracker.NewContext(context.Background()), "alice")
	fmt.Println("alice's next request:", db.ForRead(next))

	other := withUser(tracker.NewContext(context.Background()), "bob")
	fmt.Println("bob's request:", db.ForRead(other))
}
Output:
before a write: replica
after a write: primary
alice's next request: primary
bob's request: replica
Example (DatabaseSQL)

A Router holds any handle. With database/sql:

package main

import (
	"context"
	"database/sql"
	"net/http"

	"github.com/mpyw/dbstick"
)

type userKey struct{}

func userID(ctx context.Context) string {
	id, _ := ctx.Value(userKey{}).(string)
	return id
}

func main() {
	primary, _ := sql.Open("pgx", "postgres://primary/app") // errors elided
	replica, _ := sql.Open("pgx", "postgres://replica/app")
	tracker := dbstick.NewTracker(dbstick.NewMemoryStore(), userID)
	db := dbstick.NewRouter(tracker, primary, replica)

	http.HandleFunc("POST /posts", func(w http.ResponseWriter, r *http.Request) {
		// The primary's driver hook, such as pgxstick, records the write.
		_, err := db.ForWrite().ExecContext(r.Context(), "INSERT INTO posts ...")
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		http.Redirect(w, r, "/posts", http.StatusSeeOther)
	})
	http.HandleFunc("GET /posts", func(w http.ResponseWriter, r *http.Request) {
		// Within the TTL of the POST, this reads from the primary.
		rows, err := db.ForRead(r.Context()).QueryContext(r.Context(), "SELECT ...")
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		defer func() { _ = rows.Close() }() // a read has nothing to lose
	})
	_ = http.ListenAndServe(":8080", tracker.Middleware(http.DefaultServeMux)) // errors elided
}

func NewRouter

func NewRouter[DB any](tracker *Tracker, primary, replica DB) *Router[DB]

NewRouter returns a Router that asks tracker which handle to use. It panics if tracker is nil.

func (*Router[DB]) ForRead

func (r *Router[DB]) ForRead(ctx context.Context) DB

ForRead returns the handle to read with: the primary when Tracker.IsSticky says so, or else the replica.

func (*Router[DB]) ForWrite

func (r *Router[DB]) ForWrite() DB

ForWrite returns the primary. It records nothing: a driver hook records each write when it is done, such as the tracer of github.com/mpyw/dbstick/pgxstick.

Use it for reads that must see the latest data too, such as SELECT ... FOR UPDATE.

Example
package main

import (
	"context"
	"fmt"

	"github.com/mpyw/dbstick"
)

type userKey struct{}

// withUser stands for an auth middleware.
func withUser(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, userKey{}, id)
}

func userID(ctx context.Context) string {
	id, _ := ctx.Value(userKey{}).(string)
	return id
}

func main() {
	tracker := dbstick.NewTracker(dbstick.NewMemoryStore(), userID)
	db := dbstick.NewRouter(tracker, "primary", "replica")

	// ForWrite records nothing. So a read that must see the latest data,
	// such as SELECT ... FOR UPDATE, may use it too.
	ctx := withUser(tracker.NewContext(context.Background()), "alice")
	fmt.Println(db.ForWrite(), db.ForRead(ctx))
}
Output:
primary replica

type Store

type Store interface {
	// Exists reports whether key was put and has not expired.
	Exists(ctx context.Context, key string) (bool, error)
	// Put stores key for ttl. A later Put replaces the expiry.
	Put(ctx context.Context, key string, ttl time.Duration) error
}

Store remembers which keys wrote recently. Every app server must share it, or a request that reaches another server reads from the replica.

Its methods may be called at the same time from many goroutines.

A call must not block for long, since a scope waits for it. Put gets a context that is never canceled, since the write is done. So a Store that talks to a server needs a timeout of its own, as go-redis has.

type Tracker

type Tracker struct {
	// contains filtered or unexported fields
}

Tracker remembers writes, within a scope and across scopes. A scope is a unit of work such as a request or a job, started by Tracker.NewContext or Tracker.Middleware.

Its methods may be called at the same time from many goroutines, including from goroutines of one scope.

func NewTracker

func NewTracker(store Store, key func(ctx context.Context) string, opts ...TrackerOption) *Tracker

NewTracker returns a Tracker that keeps keys in store. It panics if store or key is nil.

key returns the key of whoever acts in ctx, such as a user ID that an auth middleware put there. It returns "" when there is none, such as for a guest. A scope without a key still reads its own writes, but its next one does not.

key is called when a read or a write needs it, not when a scope starts. So an auth middleware may run before or after Tracker.Middleware.

Example
package main

import (
	"context"
	"fmt"
	"log/slog"
	"time"

	"github.com/mpyw/dbstick"
)

type userKey struct{}

// withUser stands for an auth middleware.
func withUser(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, userKey{}, id)
}

func userID(ctx context.Context) string {
	id, _ := ctx.Value(userKey{}).(string)
	return id
}

func main() {
	tracker := dbstick.NewTracker(
		dbstick.NewMemoryStore(),
		userID,
		dbstick.WithTTL(10*time.Second),
		dbstick.WithPrefix("main:"),
		dbstick.WithErrorHandler(func(ctx context.Context, err error) {
			slog.WarnContext(ctx, "dbstick", "err", err)
		}),
	)
	db := dbstick.NewRouter(tracker, "primary", "replica")

	ctx := withUser(tracker.NewContext(context.Background()), "alice")
	fmt.Println(db.ForRead(ctx))
}
Output:
replica

func (*Tracker) IsSticky

func (t *Tracker) IsSticky(ctx context.Context) bool

IsSticky reports whether reads in ctx should go to the primary: the scope is sticky, or its key wrote within the TTL. It looks up the Store at most once per scope and key, and only while the scope is not sticky.

A lookup holds the scope's lock, so the scope's other goroutines wait for it.

func (*Tracker) MarkCommitted

func (t *Tracker) MarkCommitted(ctx context.Context)

MarkCommitted records that a transaction committed. If the scope wrote in it, the key is put in the Store, as Tracker.MarkWritten does.

The scope keeps one flag for its transactions, not one per transaction. So if a scope runs two at the same time and only one commits, the key is still put. That reads from the primary more than needed, but never from a stale replica.

func (*Tracker) MarkRolledBack

func (t *Tracker) MarkRolledBack(ctx context.Context)

MarkRolledBack records that a transaction rolled back. Its writes are not put in the Store. The scope stays sticky, as a scope never stops being sticky.

func (*Tracker) MarkUncommitted

func (t *Tracker) MarkUncommitted(ctx context.Context)

MarkUncommitted records a write in a transaction that has not committed yet. The scope becomes sticky at once. The key is put in the Store only by Tracker.MarkCommitted, so that the TTL starts at the commit.

Example
package main

import (
	"context"
	"fmt"

	"github.com/mpyw/dbstick"
)

type userKey struct{}

// withUser stands for an auth middleware.
func withUser(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, userKey{}, id)
}

func userID(ctx context.Context) string {
	id, _ := ctx.Value(userKey{}).(string)
	return id
}

func main() {
	tracker := dbstick.NewTracker(dbstick.NewMemoryStore(), userID)
	db := dbstick.NewRouter(tracker, "primary", "replica")

	// A driver hook calls these, around a transaction.
	ctx := withUser(tracker.NewContext(context.Background()), "alice")
	tracker.MarkUncommitted(ctx) // INSERT ... in the transaction
	fmt.Println("in this request:", db.ForRead(ctx))

	next := withUser(tracker.NewContext(context.Background()), "alice")
	fmt.Println("next, before the commit:", db.ForRead(next))

	tracker.MarkCommitted(ctx) // COMMIT
	next = withUser(tracker.NewContext(context.Background()), "alice")
	fmt.Println("next, after the commit:", db.ForRead(next))
}
Output:
in this request: primary
next, before the commit: replica
next, after the commit: primary

func (*Tracker) MarkWritten

func (t *Tracker) MarkWritten(ctx context.Context)

MarkWritten records a write that is done, such as one outside a transaction. The scope becomes sticky, and the key's later scopes read from the primary for the TTL.

A driver hook calls it, such as the tracer of github.com/mpyw/dbstick/pgxstick. Call it by hand only for a database that has no hook.

It puts the key in the Store on each call, or at most once per the interval of WithPutInterval. The Put is not canceled with ctx: the write is done, so it must be remembered.

func (*Tracker) Middleware

func (t *Tracker) Middleware(next http.Handler) http.Handler

Middleware gives each request a new scope, as Tracker.NewContext does.

Example
package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/mpyw/dbstick"
)

type userKey struct{}

// withUser stands for an auth middleware.
func withUser(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, userKey{}, id)
}

func userID(ctx context.Context) string {
	id, _ := ctx.Value(userKey{}).(string)
	return id
}

func main() {
	tracker := dbstick.NewTracker(dbstick.NewMemoryStore(), userID)
	db := dbstick.NewRouter(tracker, "primary", "replica")

	h := tracker.Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
		ctx := withUser(r.Context(), "alice")
		if r.Method == http.MethodPost {
			_ = db.ForWrite()
			tracker.MarkWritten(ctx)
		}
		fmt.Println(r.Method, "reads from", db.ForRead(ctx))
	}))
	for _, method := range []string{"GET", "POST", "GET"} {
		h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(method, "/", nil))
	}
}
Output:
GET reads from replica
POST reads from primary
GET reads from primary

func (*Tracker) NewContext

func (t *Tracker) NewContext(ctx context.Context) context.Context

NewContext returns a context with a new scope. Reads in it go to the replica until it writes, or until its key is found in the Store.

A request handler gets one from Tracker.Middleware. Call NewContext for other work, such as a job or a gRPC call. Outside any scope, a read always goes to the replica and a write is not remembered.

A scope stays sticky until it ends, and looks up the Store once per key. So keep a scope to one short piece of work. Give a long job, or each message of a WebSocket connection, a scope of its own.

func (*Tracker) NewStickyContext

func (t *Tracker) NewStickyContext(ctx context.Context) context.Context

NewStickyContext returns a context with a new scope whose reads all go to the primary, as if it had written. Use it for work that must read the latest data, such as a job that runs right after the write that queued it. It is ShouldAssumeModified of the Laravel package.

Example
package main

import (
	"context"
	"fmt"

	"github.com/mpyw/dbstick"
)

type userKey struct{}

func userID(ctx context.Context) string {
	id, _ := ctx.Value(userKey{}).(string)
	return id
}

func main() {
	tracker := dbstick.NewTracker(dbstick.NewMemoryStore(), userID)
	db := dbstick.NewRouter(tracker, "primary", "replica")

	job := tracker.NewStickyContext(context.Background())
	fmt.Println(db.ForRead(job))
}
Output:
primary

type TrackerOption

type TrackerOption func(*Tracker)

TrackerOption configures a Tracker in NewTracker.

func WithErrorHandler

func WithErrorHandler(f func(ctx context.Context, err error)) TrackerOption

WithErrorHandler sets the function that receives each error of the Store. A read whose lookup fails goes to the primary, and a write whose Put fails is not remembered beyond its scope. The default, and nil, ignore the errors.

func WithPrefix

func WithPrefix(prefix string) TrackerOption

WithPrefix sets what starts every key in the Store. Two Trackers that share a Store, such as one per database cluster, need different prefixes. The default is "dbstick:".

func WithPutInterval

func WithPutInterval(interval time.Duration) TrackerOption

WithPutInterval puts a key in the Store at most once per interval, within one scope. It saves calls to the Store when a scope writes many times. The default, 0, puts on every write. It panics if interval is negative.

A Put then keeps the key for the TTL plus interval. So reads still stay on the primary for at least the TTL after the last write, and for at most interval longer.

func WithTTL

func WithTTL(ttl time.Duration) TrackerOption

WithTTL sets how long reads stay on the primary after a write. It should be longer than the replica's lag. The default is 5 seconds, as in the Laravel package. It panics if ttl is not positive.

Directories

Path Synopsis
gormstick module
pgxstick module
redisstore module
sqlstick module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL