database

package
v11.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: AGPL-3.0 Imports: 18 Imported by: 0

Documentation

Overview

Package database stores session records in a SQL table.

Reach for it when losing the cache must not sign everybody out, or when a sign-out has to be enforceable rather than very nearly enforceable. Otherwise sessions/cache is cheaper and does the same job.

backend, _ := sessionsdatabase.NewBackend[Principal](
	&sessionsdatabase.Config{TablePrefix: "ddb"}, db,
	sessionsdatabase.WithSweeper(ctx, 5*time.Minute),
)
store, _ := sessions.NewStore(backend)

The dialect is taken from the database.Client rather than configured. A configured dialect that disagrees with the client it is paired with produces syntactically valid SQL the server rejects at runtime, and there is no way to notice before it does.

What the table buys

Two operations are single statements here and approximations in the cache backend, and both matter for the same reason — a request that read a session just before it ended must not be able to write it back afterwards:

Update is one UPDATE with a WHERE clause. A row that has been deleted is not recreated, so a sign-out that has committed stays committed.

Rename runs its DELETE and INSERT in one transaction. Either the old identifier stops resolving and the new one starts, or neither does. There is no interval in which both work, which is the interval a session-fixation attack needs.

Sweeping

A cache reclaims its own expired entries; a table does not. Without a sweep this one grows with every session ever created — the rows are unusable long before they are removed, since the store decides expiry from the record's own anchors, but they are still there.

WithSweeper runs it in-process on a timer. In a fleet that is one sweeper per replica doing the same delete; a scheduler calling Sweep once is the better shape, and the reason Sweep is exported.

The schema

The DDL lives in the migrations subpackage, rendered for a dialect and a table prefix. It is not shipped as a numbered migration file — those are numbered globally per consumer, so a platform-owned number would collide with theirs.

expires_at exists for the sweeper alone and appears in no read predicate. Which sessions are live is decided above this layer, from created_at and last_seen_at against the store's policy, so that both backends answer the question identically — and so that clock skew between a writer and a reader cannot hide a live session.

created_at appears in no UPDATE. It anchors the absolute timeout, and leaving it out of the statement makes "an update never extends a session's total lifetime" structural rather than a rule somebody has to remember.

Encoding

Payloads go into the data column through an encoding.Codec, CBOR by default. A session with no payload stores NULL and reads back as nil rather than as a zero value.

Rows written with one encoding are unreadable through another and carry no record of which wrote them, so changing WithCodec on a deployed store signs everybody out. Record.Version cannot soften that: it is a column, not part of the blob, so it catches a changed payload shape and not a changed encoding.

Index

Constants

View Source
const DefaultPayloadContentType = encoding.ContentTypeCBOR

DefaultPayloadContentType is what session payloads are encoded as when no codec is supplied: CBOR, which is compact, binary, and readable outside Go.

View Source
const DefaultTablePrefix = ""

DefaultTablePrefix is the namespace the session table carries when none is configured, which is none — rendering plain "sessions".

The sessions segment is the schema's, not the caller's: a table always says which package created it. Setting a namespace of "ddb" renders ddb_sessions, for a database shared between applications. A namespace must not end in '_'; database/ddl supplies the separator.

Variables

View Source
var ErrNilClient = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil session database client")

ErrNilClient indicates NewBackend was called without a database client. It wraps errors.ErrNilInputParameter, so a caller may check either.

Functions

This section is empty.

Types

type Backend

type Backend[T any] struct {
	// contains filtered or unexported fields
}

Backend stores session records in a SQL table.

It is a concrete type rather than an interface because it does one thing more than sessions.Backend describes: Sweep removes rows whose deadlines have passed. A cache expires its own entries and needs no equivalent, so the method does not belong on the interface — but a caller who chose this backend has to be able to reach it.

func NewBackend

func NewBackend[T any](cfg *Config, db database.Client, opts ...Option) (*Backend[T], error)

NewBackend builds a Backend over a database client.

Reads go through the write pool, deliberately. A session is written and then read on the very next request, and replica lag turns that into a sign-in that did not take — the one failure a user retries by signing in again, producing another session that also appears not to exist. Session rows are small, single-key, and short-lived; they are not the reads worth scaling out.

func (*Backend[T]) Close

func (b *Backend[T]) Close() error

Close releases the database client.

func (*Backend[T]) Create

func (b *Backend[T]) Create(
	ctx context.Context,
	id string,
	record *sessions.Record[T],
	ttl time.Duration,
) error

Create inserts a session row.

func (*Backend[T]) Delete

func (b *Backend[T]) Delete(ctx context.Context, id string) error

Delete removes the row stored under id. A row that was already gone is not an error.

func (*Backend[T]) Load

func (b *Backend[T]) Load(ctx context.Context, id string) (*sessions.Record[T], error)

Load reads the record stored under id.

func (*Backend[T]) Rename

func (b *Backend[T]) Rename(
	ctx context.Context,
	oldID, newID string,
	record *sessions.Record[T],
	ttl time.Duration,
) error

Rename moves a record from oldID to newID inside one transaction.

This is the operation the database backend exists for. Either the old identifier stops resolving and the new one starts, or neither happens; there is no interval in which both work, which is the interval a fixation attack needs.

func (*Backend[T]) Sweep

func (b *Backend[T]) Sweep(ctx context.Context) (int64, error)

Sweep removes every row whose deadline has passed, reporting how many it removed.

It is not what makes a session expire — the store decides that from the record's own anchors, so a row this has not reached yet is already unusable. What it does is stop the table growing with every session ever created.

One statement, no batching. Session rows are small and the index on expires_at makes the delete proportional to what is actually dead rather than to the table; a fleet that outgrows that wants a scheduled sweep with its own batching rather than a bigger one here.

func (*Backend[T]) Update

func (b *Backend[T]) Update(
	ctx context.Context,
	id string,
	record *sessions.Record[T],
	ttl time.Duration,
) error

Update overwrites an existing session row.

The WHERE clause is the whole guarantee: a row that has been deleted is not recreated, so a request that read a session immediately before it was signed out cannot write it back afterwards. That is a single statement here and only an approximation in the cache backend.

type Config

type Config struct {

	// TablePrefix is the namespace prepended to the session table's name. Empty
	// renders the schema's own name, "sessions"; set it to share a database
	// between applications, which renders e.g. ddb_sessions. It must not end in
	// '_' — the separator is supplied for you.
	TablePrefix string `env:"TABLE_PREFIX" json:"tablePrefix,omitempty" yaml:"tablePrefix,omitempty"`
	// contains filtered or unexported fields
}

Config configures a Backend.

It carries no dialect. The dialect comes from the database.Client, which is the only place it can come from and be right: a configured dialect that disagrees with the client it is paired with produces syntactically valid SQL the server rejects at runtime.

func (*Config) ValidateWithContext

func (cfg *Config) ValidateWithContext(ctx context.Context) error

ValidateWithContext validates a Config struct.

The prefix is vetted against the schema rather than against a pattern, because a prefix that is a legal identifier on its own can still push the index name past what the supported engines accept — and that failure would otherwise surface as a migration that half ran.

type Option

type Option func(*options)

Option configures a Backend at construction.

It carries no type parameter even though NewBackend does: Go cannot infer a type argument from a call's result type, so an Option[T] would force every call site to spell the payload type out by hand forever.

func WithClock

func WithClock(c clock.Clock) Option

WithClock swaps the clock the expires_at column is stamped from, and the one the sweeper ticks on.

func WithCodec

func WithCodec(codec encoding.Codec) Option

WithCodec sets how session payloads are encoded into the data column.

Rows written with one encoding are unreadable through another, and a stored row carries no record of which wrote it. Changing this on a deployed store therefore signs everybody out — the payloads decode to nothing, which Record.Version cannot help with because the version is a column rather than part of the blob. Choose it once.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger. An absent logger logs nowhere.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider for the sweeper's counters. An absent one records nothing.

func WithSweeper

func WithSweeper(ctx context.Context, interval time.Duration) Option

WithSweeper starts a background sweep that removes rows whose deadlines have passed, every interval, until ctx is done.

Unlike a cache, a table does not reclaim its own expired rows, and without a sweep this one grows with every session ever created. Running it is not optional in any long-lived deployment; what is optional is running it here rather than from a scheduler that calls Sweep, which is the better answer for a fleet — one sweeper, not one per replica.

The context bounds the goroutine's life. Passing a nil context or a non-positive interval starts nothing.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider. An absent one traces nowhere.

Directories

Path Synopsis
Package migrations supplies the session table's DDL, rendered for a dialect and table prefix.
Package migrations supplies the session table's DDL, rendered for a dialect and table prefix.

Jump to

Keyboard shortcuts

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