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

cache

package
v0.6.7 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 12 Imported by: 3

Documentation

Overview

Package cache holds an answer for a short while, and tells the other replicas when to stop holding it.

Everything else rig ships puts shared state in Postgres. Rate-limit tallies are rows, idempotency keys are rows, notification claims are leases, presence is a table — and the reason is written down in five generators as one sentence: a cron job rather than something racing itself in every replica. This package is the departure, so it owes an argument.

The argument is that some reads are pure functions of data that changes a few times a month and are paid for on every single request. Resolving what a caller may do is the example rig cares about: a join over role tables, twice a second per active person, for an answer that last changed when somebody was hired. The usual objection is not cost but staleness — a permission taken away has to stop working now, not when a timer expires — and that objection is correct, which is why a bare time-to-live cache over authorization is a bad trade and rig has never shipped one.

So the time-to-live is not the mechanism here. Bus is. Postgres NOTIFY issued inside a transaction is delivered exactly when that transaction commits, and discarded if it rolls back, to every session listening. That is an invalidation that is atomic with the write causing it, with no outbox, no trigger, no table and no second piece of infrastructure. A role change publishes; every replica forgets; the next request reads the new answer. The time-to-live stops being the guarantee and becomes the backstop.

Using it

A Map per kind of thing, a Bus to carry the invalidations, and a Topic from Bus.Serve that joins the two:

bus := cache.NewBus(cache.BusConfig{Pool: pool, Logger: logger})
grants := cache.NewMap[grant](cache.MapConfig{TTL: 30 * time.Second, Live: bus.Live})
topic := bus.Serve("grants", grants)

bus.Start()
app.CloseWithin("cache", 5*time.Second, bus.Close)

Reads go through Map.Load. Writes publish on the topic, inside the transaction that did the writing:

roles, err := grants.Load(tenantID.String()+"/"+accountID.String(), func() (grant, error) {
	return readGrants(ctx, db, tenantID, accountID)
})

// in a service's After hook, where dbx.Tx(ctx) is the write's transaction
if tx, ok := dbx.Tx(ctx); ok {
	err = topic.Forget(ctx, tx, tenantID.String()+"/"+accountID.String())
}

What it does not do

It does not deduplicate concurrent misses. Ten requests for one cold key make ten calls, which is what the same ten requests cost today with no cache at all — so this is a bound that was never improved rather than one made worse. Map.Load does keep a register of the loads running right now, and that is not a first step towards collapsing them: one shared answer would make one caller's failure and one caller's deadline into everybody's, and a slow leader into a queue. What the register is for is knowing which load an invalidation was about.

It is not a store. Nothing here is durable, nothing is shared, and a value put in one replica is never read from another. The only thing that crosses the wire is the word "forget".

Index

Examples

Constants

View Source
const (
	// DefaultChannel is the Postgres channel a [Bus] listens on when nothing
	// else was asked for.
	DefaultChannel = "rig_cache"

	// DefaultBackoff is how long a [Bus] waits before reconnecting.
	DefaultBackoff = time.Second

	// MaxPayload is the most a notification may carry. Postgres refuses more,
	// and it refuses at the moment of the write rather than at the moment of the
	// configuration — so this is checked here, where the error can name the key.
	MaxPayload = 7999
)
View Source
const DefaultMaxEntries = 50_000

DefaultMaxEntries bounds a Map that did not say.

Variables

This section is empty.

Functions

This section is empty.

Types

type Bus

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

Bus carries "forget this" between replicas, over one Postgres channel.

One channel for every topic, rather than a channel each. A channel is a subscription that has to be established on a connection, so one of them means one LISTEN and means a topic can be added without reconnecting; the topic travels in the payload instead, where it costs nothing.

Zero value is not usable; call NewBus. Safe for concurrent use.

func NewBus

func NewBus(cfg BusConfig) *Bus

NewBus builds a bus. It does nothing until Bus.Start.

It panics on a configuration that cannot work — no pool, or a channel name that is not an identifier — rather than failing later at the first notification. Both are decided when the process starts and neither can be fixed at the point it would otherwise be noticed.

func (*Bus) Close

func (b *Bus) Close(ctx context.Context) error

Close stops the listener and waits for it.

Register it with the shutdown, within a timeout: there is nothing in flight worth finishing, so this only needs long enough to close a connection.

app.CloseWithin("cache", 5*time.Second, bus.Close)

A bus that was never started has nothing to wait for and returns at once, which is what a project that registered the shutdown and left the cache disabled has.

func (*Bus) Live

func (b *Bus) Live() bool

Live reports whether invalidations are currently arriving.

False before Bus.Start, false whenever the listener is disconnected, and false after Bus.Close. It is what belongs in MapConfig.Live, and a cache that consults it stops caching rather than serving what it cannot withdraw.

func (*Bus) Serve

func (b *Bus) Serve(topic string, f Forgetter) *Topic

Serve registers a cache under a topic and returns the handle a writer publishes on.

The registration and the publisher are one call because they are two halves of one agreement. A topic that is listened to under one name and published under another is a cache that never invalidates and never says so, and the way to make that unrepresentable is to never let anybody type the name twice.

Returning a Topic is also what lets a project with no cache configured pass a nil one around: every method on it is a no-op, so the call sites that publish do not need a condition.

It panics on a duplicate topic, an empty one, or one holding a colon — all three are wiring mistakes that a process should not start with.

func (*Bus) Start

func (b *Bus) Start()

Start connects the listener and keeps it connected.

Idempotent, and safe on a bus that has already been closed.

Every connection begins by clearing every registered cache, and that is the property the whole design rests on. LISTEN has no backlog: a process that was disconnected for a second cannot ask what it missed, and nothing on the server remembers. So a bus that has just connected treats everything it holds as wrong, which turns "we lost the channel" from a correctness problem into a cold cache.

Register the shutdown before the pool closes:

bus.Start()
app.CloseWithin("cache", 5*time.Second, bus.Close)

type BusConfig

type BusConfig struct {
	// Pool is the application's pool. The bus does not run queries on it — it
	// takes the pool's connection configuration and opens one connection of its
	// own, because a session that is listening is a session that is blocked, and
	// a pooled connection held for the life of the process is one the
	// application never gets back.
	//
	// Taking the pool rather than a DSN is what keeps the listener on the same
	// database, credentials and TLS settings as everything else, with no second
	// piece of configuration to drift.
	Pool *pgxpool.Pool

	// Logger receives a warning when invalidations stop arriving, and an info
	// line when they resume. Once each, on the change rather than on the
	// reconnect attempt. Zero means [slog.Default].
	//
	// The two loops rig already runs in-process — the notification dispatcher
	// and the presence sweeper — deliberately log nothing, because a pass that
	// failed will be retried and nobody needs to hear about it. This one logs,
	// for the same reason the generated rate limiter does when its counters are
	// unavailable: the fallback is silent and changes what the process is
	// serving, so the only way anybody learns is if it says so.
	Logger *slog.Logger

	// Channel is the Postgres channel to use. Zero means [DefaultChannel].
	//
	// Two deployments sharing one database and not wanting to share
	// invalidations is the reason it is configurable. Both halves have to agree,
	// which is why [Bus.Serve] hands out the publisher rather than letting a
	// caller name the channel at the write.
	Channel string

	// Backoff is how long to wait after a failed or dropped connection before
	// trying again. Zero means [DefaultBackoff].
	Backoff time.Duration
}

BusConfig is what a Bus needs.

type Forgetter

type Forgetter interface {
	// Forget drops one key.
	Forget(key string)
	// Clear drops everything.
	Clear()
}

Forgetter is the part of a Map a Bus needs, so that one bus can route invalidations to maps holding different types.

Both methods are what a notification means rather than what a caller wants: Forget for one key that changed, Clear for "this process has been out of touch and cannot know what it missed".

type Keyed

type Keyed[V any] struct {
	// contains filtered or unexported fields
}

Keyed is a Map and the Topic that withdraws from it, as one thing.

One type rather than a map and a topic side by side because the two are halves of one agreement: a bus serving "session" under one name and a revocation publishing under another is a cache that never invalidates. Registering the map and handing back the publisher in the same call types the name once, and leaves nowhere for it to drift.

Four decisions, spelled once

Every method is safe on a nil receiver, which is what a cache somebody decided against looks like: Keyed.Load calls through, and the rest are no-ops. So no call site that withdraws a value needs a condition around it.

A failure is never held. That is Map's behaviour and this borrows it as a seam: a loader that turns "no such row" into a sentinel error produces the answer without anything remembering it. It matters most where the key is attacker-supplied — caching "no such token" would let anybody fill the map with invented identifiers and evict every real entry in it.

A held value is copied on the way out, if KeyedConfig.Clone says how. Map recommends the opposite — clone in the loader, once per miss — and this contradicts it deliberately: without a cache every caller got its own row out of the store, so with one every caller has to get its own value, or the cache would have changed something other than where the read happened.

And a cache that cannot withdraw holds nothing. Not yet attached and attached to a channel that has dropped are the same answer for different reasons, and both give it: the map reads through and stores nothing.

Safe for concurrent use.

func NewKeyed

func NewKeyed[V any](cfg KeyedConfig[V]) *Keyed[V]

NewKeyed builds one, serving it on KeyedConfig.Bus if there is one.

func (*Keyed[V]) Clear

func (k *Keyed[V]) Clear(ctx context.Context, db dbx.Conn) error

Clear withdraws every held answer, everywhere.

For a write that changes what an answer *means* rather than which one is wrong. Working out which keys those reach is a query, and "clear it" is the cheaper answer for a map that refills itself on the next request.

On a cache served on no channel this empties the map directly, for the reason Keyed.Forget gives.

func (*Keyed[V]) Drop

func (k *Keyed[V]) Drop(keys ...string)

Drop forgets in this process only.

The wrong tool for a revocation and the right one for a caller with no transaction to publish on — a store that is not Postgres, or a write no other replica needs to hear about.

func (*Keyed[V]) Forget

func (k *Keyed[V]) Forget(ctx context.Context, db dbx.Conn, keys ...string) error

Forget publishes an invalidation for each key, on db.

Postgres delivers a notification when the transaction issuing it commits and discards it when that transaction rolls back, so an invalidation published on the transaction that caused it is atomic with the change. The publisher hears its own notification, which is why nothing is dropped locally as well.

A cache served on no channel has no notification to hear, so there it drops what it holds directly — see Keyed.ServeLocally. Publishing nowhere *and* keeping the value would be the one arrangement where a withdrawal silently does not happen, and it is the arrangement a single-instance deployment is in.

func (*Keyed[V]) ForgetOrDrop

func (k *Keyed[V]) ForgetOrDrop(ctx context.Context, keys ...string) error

ForgetOrDrop publishes on the transaction already on ctx, or drops locally when there is none.

One caller shape: a write that may or may not be inside a transaction. A Postgres store always is, and a store that is not Postgres has no channel to publish on and no other replica to reach — so the two answers are the same decision made from what the context carries.

func (*Keyed[V]) Len

func (k *Keyed[V]) Len() int

Len is how many entries are held, expired ones included. For a metric or a test.

func (*Keyed[V]) Load

func (k *Keyed[V]) Load(key string, fn func() (V, error)) (V, error)

Load answers from the cache, or calls fn and keeps what it returns.

A failure is never kept, which is the seam a miss borrows: see the type's own documentation. The sentinel is the caller's, because only the caller knows what to translate it back into.

On a nil receiver fn's value is returned as it is — nothing was cached, so nothing needs copying.

func (*Keyed[V]) Serve

func (k *Keyed[V]) Serve(bus *Bus)

Serve attaches the cache to a bus, so that what it holds can be withdrawn everywhere.

Nothing is held until this runs. It panics if the bus already serves the configured topic, which is the wiring mistake of attaching two caches to one name.

func (*Keyed[V]) ServeLocally

func (k *Keyed[V]) ServeLocally()

ServeLocally attaches the cache to nothing, so it holds values and forgets them in this process alone.

One process, no replicas, and staleness is the caller's problem. It exists because "attached to no channel" is a real posture and the alternative is a test reaching into an unexported field to reach the same state.

type KeyedConfig

type KeyedConfig[V any] struct {
	// Bus is the invalidation channel and Topic the name this cache's
	// invalidations travel under.
	//
	// A non-nil Bus is served immediately, and panics if that bus already serves
	// Topic — the wiring mistake of attaching two caches to one name. A nil Bus
	// builds a cache attached to nothing, which holds nothing until
	// [Keyed.Serve]: that is what a cache built before the bus it will run on
	// has, and what a project with no `cache:` block has for good.
	Bus   *Bus
	Topic string

	// TTL, MaxEntries and Now are [MapConfig]'s, unchanged.
	//
	// Zero TTL caches nothing. A caller with a default of its own resolves it
	// before it gets here, because "off" and "the package default" are different
	// answers and only the caller knows which it meant.
	TTL        time.Duration
	MaxEntries int
	Now        func() time.Time

	// Clone copies a value on the way out of [Keyed.Load]. Nil is identity, which
	// is right for a V with nothing a caller could write through.
	Clone func(V) V
}

KeyedConfig is what a Keyed needs.

type Map

type Map[V any] struct {
	// contains filtered or unexported fields
}

Map keeps values against string keys until their time-to-live runs out or something forgets them.

Keys are strings, and that is a decision rather than a shortcut. The key a notification carries and the key a lookup uses have to be the same key, and the cheapest way to guarantee it is to have only one of them. A typed key with a formatting function beside it is two keys that agree until somebody edits one, and the failure that follows is a cache that silently never invalidates — which is indistinguishable from a cache that works, right up until it matters.

Safe for concurrent use. Zero value is not usable; call NewMap.

Example

What a cache is for: the answer is read once and served from memory until something says otherwise. Here that answer is what a caller may do, which is the read rig cares about — a join over role tables, on every request, for something that last changed when somebody was hired.

package main

import (
	"fmt"
	"time"

	"github.com/simonjanss/rig/runtime/cache"
)

func main() {
	grants := cache.NewMap[[]string](cache.MapConfig{TTL: time.Minute})

	asked := 0
	read := func() ([]string, error) {
		asked++
		return []string{"note.read", "note.write"}, nil
	}

	for range 3 {
		held, err := grants.Load("tenant-1/account-1", read)
		if err != nil {
			panic(err)
		}
		fmt.Println(held)
	}
	fmt.Println("times asked:", asked)

}
Output:
[note.read note.write]
[note.read note.write]
[note.read note.write]
times asked: 1

func NewMap

func NewMap[V any](cfg MapConfig) *Map[V]

NewMap builds a map from a configuration.

Example

A zero time-to-live caches nothing and calls through every time. That is so a project turns the cache off by changing a number rather than by unpicking its wiring, and so a duration read from configuration needs no condition around it.

package main

import (
	"fmt"

	"github.com/simonjanss/rig/runtime/cache"
)

func main() {
	off := cache.NewMap[string](cache.MapConfig{TTL: 0})

	asked := 0
	read := func() (string, error) {
		asked++
		return "admin", nil
	}
	for range 3 {
		if _, err := off.Load("tenant-1/account-1", read); err != nil {
			panic(err)
		}
	}

	fmt.Println("times asked: ", asked)
	fmt.Println("entries held:", off.Len())

}
Output:
times asked:  3
entries held: 0

func (*Map[V]) Clear

func (m *Map[V]) Clear()

Clear drops everything, the loads in flight included: what a caller is part way through reading is as likely to be stale as what the map is holding.

This is what a Bus calls when it has just connected, because whatever was published while it was away is unrecoverable — see Bus.Start. It is the one invalidation that cannot name a key, and the only reason Map.Load still counts anything for the map as a whole.

func (*Map[V]) Forget

func (m *Map[V]) Forget(key string)

Forget drops one key, so that the next Map.Load calls through.

Forgetting something that was never held is not an error — a Bus delivers every replica the same notification, and most of them will not have the key. A load of this key that is in flight is told, and will not keep what it comes back with. Loads of every other key are left alone — the method takes one key and that is the whole of what it invalidates.

Example

Forget is the whole reason this package can be used over authorization at all. The middle value is what a time-to-live cache on its own gives you — a role that changed and an answer that has not noticed — and it is why a cache.Bus carries the invalidation to every replica rather than leaving each one to wait out its window.

package main

import (
	"fmt"
	"time"

	"github.com/simonjanss/rig/runtime/cache"
)

func main() {
	roles := cache.NewMap[string](cache.MapConfig{TTL: time.Minute})

	held := "member"
	read := func() (string, error) { return held, nil }

	first, _ := roles.Load("tenant-1/account-1", read)

	// Somebody is promoted. Nothing has told this process.
	held = "admin"
	stale, _ := roles.Load("tenant-1/account-1", read)

	roles.Forget("tenant-1/account-1")
	fresh, _ := roles.Load("tenant-1/account-1", read)

	fmt.Println(first, stale, fresh)

}
Output:
member member admin

func (*Map[V]) Len

func (m *Map[V]) Len() int

Len is how many entries are held, expired ones included.

For a metric or a test. It is not a measure of anything a caller should branch on: an entry counted here may be past its time-to-live, and the next Map.Load of it will call through.

func (*Map[V]) Load

func (m *Map[V]) Load(key string, fn func() (V, error)) (V, error)

Load answers from the map, or calls fn and keeps what it returns.

A failure is never kept. A database that was unreachable for one request does not get to decide the answer for the rest of the window.

The value is shared, not copied

What fn returned is stored as it is and handed back as it is. If V holds a slice, a map or a pointer — and the read this package exists for returns a grant of roles and permissions, so it does — then every caller inside the window has the same backing array, and one of them appending to it or editing it in place is editing what the others are reading. Under `-race` that is a race; without it, it is worse.

Nothing here can prevent it: copying an arbitrary V is not something a library can do. So a V with a reference type in it has to be cloned by somebody, and the cheap place is fn, once per miss, rather than at every call site that receives one.

Invalidation during a load

The interesting case is neither the hit nor the miss but the invalidation that lands in between. A caller reads, a role changes, the notification arrives and is applied, and only then does the read come back — with the old answer, about to be written into a map that has just been told to forget it. Nothing later corrects that: the entry looks fresh and survives its full time-to-live, so the one request that mattered is the one the cache gets wrong. Load closes it by registering the read before making it, and declining to store an answer an invalidation has landed on since.

It is the key's own invalidations that count, and not the map's. A Bus delivers every replica the same notification, and most of them are for keys this process has never held — so discarding on somebody else's key would be a cache that stops filling in proportion to how busy the table is. Worst exactly where it was wanted, and silent, because a cache that holds nothing and a cache that works differ only in a number nobody is looking at. The register is what makes per-key affordable: an entry in it exists only while a load of that key is running, so it is bounded by concurrent misses rather than by anything needing an eviction policy of its own.

Map.Clear is the exception and stays map-wide, because it is what a bus does on reconnecting and the one thing it knows then is that it cannot name what it missed.

The register is taken before fn is called, which is what makes the window that remains safe rather than merely small: an invalidation applied before fn starts is one fn's own read will see, so the answer it comes back with is the new one. That holds for a loader that goes and reads, and not for one reading inside a transaction opened earlier — which is why a generated Get consults the cache only when there is no transaction in the context.

Example

A failure is answered and never kept. A database that was unreachable for one request does not get to decide what somebody may do for the rest of the window — which would turn a blip into an outage lasting exactly as long as the time-to-live.

package main

import (
	"errors"
	"fmt"
	"time"

	"github.com/simonjanss/rig/runtime/cache"
)

func main() {
	grants := cache.NewMap[string](cache.MapConfig{TTL: time.Minute})

	_, err := grants.Load("tenant-1/account-1", func() (string, error) {
		return "", errors.New("connection refused")
	})
	fmt.Println("first:", err)

	held, err := grants.Load("tenant-1/account-1", func() (string, error) {
		return "admin", nil
	})
	fmt.Println("then: ", held, err)

}
Output:
first: connection refused
then:  admin <nil>
Example (Raced)

The race a cache over authorization has to survive. An invalidation lands while the read is still in flight, so what comes back is already out of date: the caller is answered, because it was true when it was read, but nothing is stored — an entry written here would look fresh and outlive the change, leaving the one request that mattered as the one the cache got wrong.

package main

import (
	"fmt"
	"time"

	"github.com/simonjanss/rig/runtime/cache"
)

func main() {
	grants := cache.NewMap[string](cache.MapConfig{TTL: time.Hour})

	answered, _ := grants.Load("tenant-1/account-1", func() (string, error) {
		grants.Forget("tenant-1/account-1")
		return "member", nil
	})

	fmt.Println("answered:    ", answered)
	fmt.Println("entries held:", grants.Len())

}
Output:
answered:     member
entries held: 0
Example (Window)

An entry is good until its expiry rather than up to it, and cache.MapConfig takes the clock so that the edge of the window is a thing a test can stand on instead of sleep towards.

package main

import (
	"fmt"
	"time"

	"github.com/simonjanss/rig/runtime/cache"
)

func main() {
	at := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
	grants := cache.NewMap[string](cache.MapConfig{
		TTL: time.Minute,
		Now: func() time.Time { return at },
	})

	asked := 0
	read := func() (string, error) {
		asked++
		return fmt.Sprintf("answer %d", asked), nil
	}

	first, _ := grants.Load("tenant-1/account-1", read)

	at = at.Add(time.Minute - 1)
	inside, _ := grants.Load("tenant-1/account-1", read)

	at = at.Add(1)
	outside, _ := grants.Load("tenant-1/account-1", read)

	fmt.Println(first, "|", inside, "|", outside)

}
Output:
answer 1 | answer 1 | answer 2

type MapConfig

type MapConfig struct {
	// TTL is how long a value may be reused, and the honest statement of what
	// the cache costs: with no invalidation reaching this process, a change
	// takes effect this long after it was made.
	//
	// Zero or less caches nothing and calls through every time. That is so a
	// project can turn the cache off by changing a number rather than by
	// unpicking its wiring, and so a value read from configuration needs no
	// condition around it.
	TTL time.Duration

	// Live reports whether invalidations are currently arriving. [Bus.Live] is
	// what belongs here.
	//
	// When it returns false the map calls through and stores nothing, which is
	// the opposite choice from the rate limiter next door: that one fails open,
	// because refusing traffic is worse than allowing a little too much, and
	// this one fails closed, because serving permissions nobody can withdraw is
	// worse than serving them slowly. The cost is real and worth saying out
	// loud — a database that has gone away puts the full read back on every
	// request at the moment it can least afford it.
	//
	// Nil means always live, which is a cache with no invalidation channel: a
	// time-to-live and nothing else, correct only in a single process or for
	// somebody who has decided the staleness is acceptable.
	Live func() bool

	// MaxEntries bounds the map. Zero means [DefaultMaxEntries].
	//
	// Past it the whole map is dropped rather than swept. That sounds worse than
	// it is: no entry outlives TTL, so reaching the bound costs one window
	// behaving as though nobody had configured a cache. Refusing to store
	// anything new instead would leave a process answering forever out of
	// whichever keys happened to arrive first, which is a cache that gets colder
	// the longer it runs.
	MaxEntries int

	// Now is the clock. Zero is [time.Now]. It is here because what is worth
	// testing about a cache is the edge of its window, and a test that sleeps to
	// reach that edge is a test that fails on a loaded machine.
	Now func() time.Time
}

MapConfig is what a Map needs to know.

type RowCache

type RowCache[V any] struct {
	// contains filtered or unexported fields
}

RowCache holds one table's rows between requests.

It is what `persist-go` puts behind a `cache:` block, and it used to be emitted: a hundred lines of generic machinery, identical in every generated project because it depends on nothing the document says, testable only by reading a golden file. Here it is ordinary Go with tests against it.

A Keyed underneath, and the two places it departs from one are the whole of what makes it a separate type. Both are written out below, against Keyed's opposite choice, because a reader who knows one will assume the other.

A write is dropped locally as well as published

Keyed.Forget publishes on the writing transaction and drops nothing here, on the grounds that the publisher hears its own notification. That is right for a revoked session, where a moment of staleness on the revoking replica is nobody's problem.

It is wrong for a row. The notification travels out through Postgres and back in on the listener's own connection, which takes some moments — and those moments belong to the caller who just wrote, who would spend them reading the row as it was. Somebody who saves a change and is shown the old value has been told their write did not happen. So RowCache.Forget does both: the publication for every other replica, on the transaction so that a rollback takes it back, and a local drop registered on the same commit so that it lands before the write returns to whoever asked for it.

Dropping an entry a rollback made valid again costs one query, and dbx.AfterCommit does not run on a rollback anyway.

There is no serving it locally

Keyed.ServeLocally is a cache attached to no channel that holds values all the same — one process, no replicas, staleness is the caller's problem. A row cache has no such posture: RowCache.Serve with a nil bus leaves it dead, and then it reads through and stores nothing.

The reason is what is being cached. A `cache:` block is over the application's own rows, written by handlers that run everywhere, and a plain time-to-live over those is the trade this whole mechanism exists to refuse. Reading through costs queries; holding a row nothing can withdraw costs correctness, and only one of those is worth defaulting to.

Safe for concurrent use. Every method is safe on a nil receiver.

func NewRowCache

func NewRowCache[V any](cfg RowCacheConfig) *RowCache[V]

NewRowCache builds a cache that holds nothing until it is served.

func (*RowCache[V]) Forget

func (c *RowCache[V]) Forget(ctx context.Context, key string) error

Forget withdraws one row: here once the change lands, and everywhere else on the transaction that made it.

Both, and each covers what the other cannot — see the type's own documentation. With no transaction on ctx the local drop is all there is, and there is no other replica it could have reached.

func (*RowCache[V]) Load

func (c *RowCache[V]) Load(key string, fn func() (V, error)) (V, error)

Load answers from the cache, or reads through it.

func (*RowCache[V]) Serve

func (c *RowCache[V]) Serve(bus *Bus)

Serve attaches this cache to a bus, so its entries can be withdrawn.

A nil bus leaves it unserved and therefore dead, for the reason the type's own documentation gives.

type RowCacheConfig

type RowCacheConfig struct {
	// Topic is the name this table's invalidations travel under, which is the
	// table's own name.
	Topic string

	// TTL and MaxEntries are [MapConfig]'s. A zero TTL holds nothing.
	TTL        time.Duration
	MaxEntries int
}

RowCacheConfig is what a RowCache needs.

No Clone: the generated store copies a row at the call site, with a clone function it wrote for that entity, and threading it through here would be a second place the copy could fail to happen.

type Topic

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

Topic is one kind of cached thing, and the handle a writer publishes on.

A nil Topic is usable and does nothing, so a project with no cache configured can hand nil to the services that would publish and leave their call sites alone.

func (*Topic) Clear

func (t *Topic) Clear(ctx context.Context, db dbx.Conn) error

Clear tells every replica to drop everything under this topic.

For the change Topic.Forget cannot express. Editing what a role grants moves every account that holds it, and an application that would have to run a query to find them is better served by dropping the topic and letting the next requests read.

func (*Topic) Forget

func (t *Topic) Forget(ctx context.Context, db dbx.Conn, key string) error

Forget tells every replica to drop one key, including this one.

**Pass the transaction that made the change.** Postgres delivers a notification when the transaction that issued it commits, and discards it if that transaction rolls back — so an invalidation published on the writing transaction is atomic with the write, and one published on the pool is a message about a change that may not have happened. In a service hook that is dbx.Tx:

if tx, ok := dbx.Tx(ctx); ok {
	err = topic.Forget(ctx, tx, key)
}

The publisher hears its own notification, which is why there is no local eviction here. One path for every replica, including the one that wrote.

Jump to

Keyboard shortcuts

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