Skip to content

What it is actually for

The shapes that fit, and the line where pub/sub stops being the right answer.

Invalidation, config reloads, live updates

Pub/sub makes cache invalidation fast; the TTL is what makes it correct. Plus a websocket hub on one subscription per process, and the test for everything else: if losing a message would page somebody, it should not have been a pub/sub message.

go
package main

import (
	"strings"
	"sync"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/repository"
)

// --- Example 3: what pub/sub is actually good at -----------------------------
//
// Three shapes that fit, and the line where it stops fitting. The test for
// everything below: if losing a message would page somebody, it should not have
// been a pub/sub message.

// --- Cache invalidation across replicas --------------------------------------
//
// The canonical use. Deleting a redis key already invalidates it everywhere,
// because there is one redis — this is for state held *in a process*, which no
// amount of deleting reaches.

type Settings struct {
	TenantID string `gorm:"primaryKey" json:"tenant_id"`
	Version  int64  `json:"version"`
}

func (Settings) TableName() string { return "settings" }

// localCache is the in-process copy. It is created per hub/registry rather than
// living in a package-level var, so two of them in one test never collide.
type localCache struct {
	mu   sync.RWMutex
	byID map[string]Settings
}

func newLocalCache() *localCache {
	return &localCache{byID: map[string]Settings{}}
}

func (l *localCache) Delete(id string) {
	l.mu.Lock()
	defer l.mu.Unlock()
	delete(l.byID, id)
}

// UpdateSettings is the writer: the row is the truth, the message only says the
// truth moved.
func UpdateSettings(ctx core.IContext, s *Settings) core.IError {
	if err := repository.New[Settings](ctx).Save(s); err != nil {
		return err
	}
	ctx.PubSub().Publish("settings.changed", s.TenantID)
	return nil
}

// onSettingsChanged runs in every replica.
//
// A replica that misses the message keeps its stale copy until the TTL runs out,
// which is exactly why the in-process copy still needs one. Pub/sub makes
// invalidation *fast*; the TTL is what makes it *correct*.
func onSettingsChanged(local *localCache) core.PubSubHandler {
	return func(ctx core.IContext, msg *core.PubSubMessage) error {
		tenant := msg.String()
		core.Forget(ctx.Cache(), "settings:"+tenant)
		local.Delete(tenant)
		return nil
	}
}

// --- Feature flags and config reloads ----------------------------------------
//
// Keep the old value when the reload fails. A reload that fails and leaves the
// service with no config is worse than one that leaves it with yesterday's.
//
// Reload on a timer as well as on the message: the timer is what makes a replica
// that happened to be restarting during the publish eventually correct.
func onConfigReload(store *localCache) core.PubSubHandler {
	return func(ctx core.IContext, msg *core.PubSubMessage) error {
		cfg, err := repository.New[Settings](ctx).FindOne("tenant_id = ?", msg.String())
		if err != nil {
			return err // logged and reported; the old config stays in place
		}

		store.mu.Lock()
		store.byID[cfg.TenantID] = *cfg
		store.mu.Unlock()

		ctx.Log().Info("config reloaded", "tenant", cfg.TenantID, "version", cfg.Version)
		return nil
	}
}

// --- Live updates to websockets ----------------------------------------------
//
// One redis subscription per *process*, fanned out in memory to however many
// sockets are connected. Ten thousand clients is then ten thousand map entries,
// not ten thousand redis connections — which redis would refuse long before you
// got there.
//
// The channel-per-user is what lets a replica ignore messages for users whose
// sockets it is not holding.
type hub struct {
	mu      sync.RWMutex
	clients map[string][]chan []byte // user id → its open sockets
}

func newHub() *hub { return &hub{clients: map[string][]chan []byte{}} }

func (h *hub) Run(app *core.App) core.IError {
	sub, err := app.PubSub().PSubscribe("notify.*")
	if err != nil {
		return err
	}
	defer sub.Close()

	for msg := range sub.C() {
		userID := strings.TrimPrefix(msg.Channel, "notify.")

		h.mu.RLock()
		for _, out := range h.clients[userID] {
			select {
			case out <- msg.Payload:
			default:
				// Deliberately dropping, rather than blocking the read loop until
				// redis disconnects this subscriber for falling behind. Choosing
				// what is lost beats being cut off silently.
				app.Log().Warn("dropping notification: socket is not keeping up",
					"user", userID)
			}
		}
		h.mu.RUnlock()
	}
	return nil
}

// Notify is the publishing side, from anywhere in the service.
func Notify(ctx core.IContext, userID, text string) {
	ctx.PubSub().Publish("notify."+userID, map[string]string{"text": text})
}

// --- Where the line is -------------------------------------------------------
//
//	work that must happen exactly once      → jobs
//	work another service must acknowledge   → MQ
//	a request/response across services      → HTTP, via Requester
//	an event stream with history            → a table, or redis streams via Redis()
//
// And the rules that follow from having no acknowledgement at all: a subscriber
// must tolerate both duplicates and losses, so write handlers that *sync state*
// ("make it match") rather than ones that *step* ("add one"). Keep them fast —
// a subscriber redis considers slow is a subscriber redis disconnects; hand
// heavy work to a job and return.

Maintained by Passakon Puttasuwan & Dev Core Team.