86215f1eb1
Three connected features for the deployment-strategy demo: 1. Visitor identity cookie (v3). A new visitors table backs a first-party gb_visitor cookie: middleware on /api checks the cookie's UUID against the database on every request and regenerates + persists it when the id is missing, malformed, or unknown (fresh browser, pruned identity, or a reset database). The identity remembers the name a visitor signed with (pre-filled next time, used for unsigned posts), replaces the standalone reaction cookie for reaction de-dupe, and feeds a live "N visiting" counter (identities seen in the last 5 minutes) shown on the wall, in /api/info, the admin overview, and the presence/tick SSE events. The admin console gains Visitors/Visiting tiles and a visitors table, plus a GET /api/me endpoint and GET /api/admin/visitors. 2. guestbook reset command. Wipes entries (including soft-deleted rows), reactions and visitor identities under the schema advisory lock, with --dry-run, --hard (also reset wall settings) and --reseed flags; meant to run as a one-off Miabi Job. After a reset every outstanding cookie is invalidated, so browsers get a fresh identity on their next request. 3. cleanup learns --prune-visitors (default 30 days): the cron job now also forgets idle identities together with their reactions. Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity talking point is in act 3, and the API tables list the new endpoints. Akili-Task: tsk_ptdi5ufndrzwlljk4v5g Akili-Agent: goldfish
356 lines
9.1 KiB
Go
356 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jkaninda/logger"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// Event is a server-to-client message pushed over SSE.
|
|
//
|
|
// Type selects the shape of the payload:
|
|
// - "created" → Entry is set (a new signature)
|
|
// - "updated" → Entry is set (pinned/hidden by a moderator)
|
|
// - "deleted" → ID is set (a removed signature)
|
|
// - "reaction" → ID/Emoji/Count are set (an entry's tally changed)
|
|
// - "settings" → Settings is set (signing paused, banner changed, theme forced)
|
|
// - "presence" → Online/Replicas/Visitors are set (connected clients or active
|
|
// visitor identities changed)
|
|
// - "tick" → Time/Version/Host are set (live server clock; new in v2),
|
|
// plus Visitors (active identities; new in v3)
|
|
type Event struct {
|
|
Type string `json:"type"`
|
|
Entry *Entry `json:"entry,omitempty"`
|
|
ID uint `json:"id,omitempty"`
|
|
Emoji string `json:"emoji,omitempty"`
|
|
Count int `json:"count,omitempty"`
|
|
Settings *Settings `json:"settings,omitempty"`
|
|
Online int `json:"online,omitempty"`
|
|
Replicas int `json:"replicas,omitempty"`
|
|
Visitors int64 `json:"visitors,omitempty"`
|
|
Time string `json:"time,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
Host string `json:"host,omitempty"`
|
|
}
|
|
|
|
// Replica describes one running instance of the app and its SSE clients.
|
|
// Requests is the number of HTTP requests it has answered since boot: under
|
|
// load-balancing the counts diverge, and a rollout resets them one replica at
|
|
// a time — the rolling update made visible.
|
|
type Replica struct {
|
|
Host string `json:"host"`
|
|
Version string `json:"version"`
|
|
Online int `json:"online"`
|
|
Requests int64 `json:"requests"`
|
|
Seen time.Time `json:"seen"`
|
|
}
|
|
|
|
const (
|
|
eventsChannel = "guestbook:events"
|
|
replicaKeyPrefix = "guestbook:replica:"
|
|
replicaTTL = 15 * time.Second
|
|
heartbeatEvery = 5 * time.Second
|
|
syncEvent = "sync"
|
|
)
|
|
|
|
// Broker fans events out to the SSE clients connected to this replica.
|
|
//
|
|
// Without Redis it is purely in-process, so a signature posted on replica A
|
|
// never reaches viewers on replica B. With Redis, published events go through
|
|
// a pub/sub channel that every replica listens to, and each replica advertises
|
|
// its client count under a TTL'd key so presence is cluster-wide.
|
|
type Broker struct {
|
|
mu sync.RWMutex
|
|
subs map[chan Event]struct{}
|
|
|
|
host, version string
|
|
rdb *redis.Client
|
|
dirty chan struct{}
|
|
requests func() int64
|
|
// visitors reports how many visitor identities were recently active (from
|
|
// the database, so it is cluster-wide by construction); may be nil.
|
|
visitors func() int64
|
|
|
|
cmu sync.RWMutex
|
|
replicas []Replica
|
|
online int
|
|
}
|
|
|
|
// NewBroker creates an in-process Broker. Call UseRedis to make it cluster-wide.
|
|
// requests reports how many HTTP requests this replica has served so far (may
|
|
// be nil); it is advertised with the presence heartbeat.
|
|
func NewBroker(host, version string, requests func() int64) *Broker {
|
|
return &Broker{
|
|
subs: make(map[chan Event]struct{}),
|
|
host: host,
|
|
version: version,
|
|
dirty: make(chan struct{}, 1),
|
|
requests: requests,
|
|
}
|
|
}
|
|
|
|
// UseRedis switches the broker to Redis pub/sub. The client reconnects on its
|
|
// own, so an unreachable Redis at startup is not fatal.
|
|
func (b *Broker) UseRedis(url string) error {
|
|
opts, err := redis.ParseURL(url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.rdb = redis.NewClient(opts)
|
|
return nil
|
|
}
|
|
|
|
// Mode reports "redis" when events are shared across replicas, else "local".
|
|
func (b *Broker) Mode() string {
|
|
if b.rdb != nil {
|
|
return "redis"
|
|
}
|
|
return "local"
|
|
}
|
|
|
|
// RedisStatus reports "disabled", "up" or "down".
|
|
func (b *Broker) RedisStatus(ctx context.Context) string {
|
|
if b.rdb == nil {
|
|
return "disabled"
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
|
defer cancel()
|
|
if err := b.rdb.Ping(ctx).Err(); err != nil {
|
|
return "down"
|
|
}
|
|
return "up"
|
|
}
|
|
|
|
// Run drives the Redis subscription and presence heartbeat until ctx is done.
|
|
// It is a no-op in local mode.
|
|
func (b *Broker) Run(ctx context.Context) {
|
|
if b.rdb == nil {
|
|
return
|
|
}
|
|
go b.listen(ctx)
|
|
|
|
t := time.NewTicker(heartbeatEvery)
|
|
defer t.Stop()
|
|
b.announce(ctx, true)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-b.dirty:
|
|
b.announce(ctx, true)
|
|
case <-t.C:
|
|
b.announce(ctx, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close withdraws this replica from the presence set.
|
|
func (b *Broker) Close(ctx context.Context) {
|
|
if b.rdb == nil {
|
|
return
|
|
}
|
|
_ = b.rdb.Del(ctx, replicaKeyPrefix+b.host).Err()
|
|
_ = b.publishRaw(ctx, Event{Type: syncEvent})
|
|
_ = b.rdb.Close()
|
|
}
|
|
|
|
// Subscribe registers a new client and returns its event channel.
|
|
func (b *Broker) Subscribe() chan Event {
|
|
ch := make(chan Event, 16)
|
|
b.mu.Lock()
|
|
b.subs[ch] = struct{}{}
|
|
b.mu.Unlock()
|
|
b.presenceChanged()
|
|
return ch
|
|
}
|
|
|
|
// Unsubscribe removes a client and closes its channel.
|
|
func (b *Broker) Unsubscribe(ch chan Event) {
|
|
b.mu.Lock()
|
|
if _, ok := b.subs[ch]; !ok {
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
delete(b.subs, ch)
|
|
close(ch)
|
|
b.mu.Unlock()
|
|
b.presenceChanged()
|
|
}
|
|
|
|
// Publish broadcasts an event to every client on every replica.
|
|
func (b *Broker) Publish(ev Event) {
|
|
if b.rdb == nil {
|
|
b.emit(ev)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := b.publishRaw(ctx, ev); err != nil {
|
|
logger.Warn("redis publish failed, delivering locally only", "type", ev.Type, "error", err)
|
|
b.emit(ev)
|
|
}
|
|
}
|
|
|
|
// PublishLocal broadcasts an event to this replica's clients only.
|
|
func (b *Broker) PublishLocal(ev Event) { b.emit(ev) }
|
|
|
|
// Online returns the number of connected clients across all replicas.
|
|
func (b *Broker) Online() int {
|
|
if b.rdb == nil {
|
|
return b.localCount()
|
|
}
|
|
b.cmu.RLock()
|
|
defer b.cmu.RUnlock()
|
|
return b.online
|
|
}
|
|
|
|
// Replicas lists the running replicas and their client counts.
|
|
func (b *Broker) Replicas() []Replica {
|
|
if b.rdb == nil {
|
|
return []Replica{b.self()}
|
|
}
|
|
b.cmu.RLock()
|
|
defer b.cmu.RUnlock()
|
|
return append([]Replica(nil), b.replicas...)
|
|
}
|
|
|
|
func (b *Broker) self() Replica {
|
|
var reqs int64
|
|
if b.requests != nil {
|
|
reqs = b.requests()
|
|
}
|
|
return Replica{Host: b.host, Version: b.version, Online: b.localCount(), Requests: reqs, Seen: time.Now().UTC()}
|
|
}
|
|
|
|
func (b *Broker) localCount() int {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
return len(b.subs)
|
|
}
|
|
|
|
func (b *Broker) presenceChanged() {
|
|
if b.rdb == nil {
|
|
b.emit(Event{Type: "presence", Online: b.localCount(), Replicas: 1, Visitors: b.visitorsActive()})
|
|
return
|
|
}
|
|
select {
|
|
case b.dirty <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// visitorsActive returns the recent-visitor count, or 0 when no reporter is
|
|
// registered.
|
|
func (b *Broker) visitorsActive() int64 {
|
|
if b.visitors == nil {
|
|
return 0
|
|
}
|
|
return b.visitors()
|
|
}
|
|
|
|
func (b *Broker) publishRaw(ctx context.Context, ev Event) error {
|
|
payload, err := json.Marshal(ev)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return b.rdb.Publish(ctx, eventsChannel, payload).Err()
|
|
}
|
|
|
|
func (b *Broker) listen(ctx context.Context) {
|
|
ps := b.rdb.Subscribe(ctx, eventsChannel)
|
|
defer func() { _ = ps.Close() }()
|
|
ch := ps.Channel()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case msg, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
var ev Event
|
|
if err := json.Unmarshal([]byte(msg.Payload), &ev); err != nil {
|
|
continue
|
|
}
|
|
if ev.Type == syncEvent {
|
|
b.refresh(ctx)
|
|
continue
|
|
}
|
|
b.emit(ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
// announce writes this replica's heartbeat and, when notify is set, tells the
|
|
// other replicas to recount presence.
|
|
func (b *Broker) announce(ctx context.Context, notify bool) {
|
|
payload, _ := json.Marshal(b.self())
|
|
if err := b.rdb.Set(ctx, replicaKeyPrefix+b.host, payload, replicaTTL).Err(); err != nil {
|
|
logger.Warn("redis heartbeat failed", "error", err)
|
|
return
|
|
}
|
|
if notify {
|
|
_ = b.publishRaw(ctx, Event{Type: syncEvent})
|
|
}
|
|
b.refresh(ctx)
|
|
}
|
|
|
|
// refresh recomputes cluster presence from the replica keys and pushes a
|
|
// presence event to local clients when it changed.
|
|
func (b *Broker) refresh(ctx context.Context) {
|
|
var keys []string
|
|
iter := b.rdb.Scan(ctx, 0, replicaKeyPrefix+"*", 100).Iterator()
|
|
for iter.Next(ctx) {
|
|
keys = append(keys, iter.Val())
|
|
}
|
|
if iter.Err() != nil || len(keys) == 0 {
|
|
return
|
|
}
|
|
vals, err := b.rdb.MGet(ctx, keys...).Result()
|
|
if err != nil {
|
|
return
|
|
}
|
|
replicas := make([]Replica, 0, len(vals))
|
|
total := 0
|
|
for _, v := range vals {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
var r Replica
|
|
if json.Unmarshal([]byte(s), &r) == nil {
|
|
replicas = append(replicas, r)
|
|
total += r.Online
|
|
}
|
|
}
|
|
sort.Slice(replicas, func(i, j int) bool { return replicas[i].Host < replicas[j].Host })
|
|
|
|
b.cmu.Lock()
|
|
changed := total != b.online || len(replicas) != len(b.replicas)
|
|
b.online, b.replicas = total, replicas
|
|
b.cmu.Unlock()
|
|
|
|
if changed {
|
|
b.emit(Event{Type: "presence", Online: total, Replicas: len(replicas), Visitors: b.visitorsActive()})
|
|
}
|
|
}
|
|
|
|
// emit fans an event out to local subscribers. Sends are non-blocking: a slow
|
|
// client that has filled its buffer misses the event rather than stalling
|
|
// the broker.
|
|
func (b *Broker) emit(ev Event) {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
for ch := range b.subs {
|
|
select {
|
|
case ch <- ev:
|
|
default:
|
|
}
|
|
}
|
|
}
|