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 ¶
- type MemoryStore
- type Router
- type Store
- type Tracker
- func (t *Tracker) IsSticky(ctx context.Context) bool
- func (t *Tracker) MarkCommitted(ctx context.Context)
- func (t *Tracker) MarkRolledBack(ctx context.Context)
- func (t *Tracker) MarkUncommitted(ctx context.Context)
- func (t *Tracker) MarkWritten(ctx context.Context)
- func (t *Tracker) Middleware(next http.Handler) http.Handler
- func (t *Tracker) NewContext(ctx context.Context) context.Context
- func (t *Tracker) NewStickyContext(ctx context.Context) context.Context
- type TrackerOption
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.
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
}
Output:
func NewRouter ¶
NewRouter returns a Router that asks tracker which handle to use. It panics if tracker is nil.
func (*Router[DB]) ForRead ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.