2026-04-06 09:00:47 +00:00
|
|
|
// Phase B Step 7 — Event Persistence Layer.
|
|
|
|
|
//
|
|
|
|
|
// EventPersister is an asynchronous batched writer that drains broadcast
|
|
|
|
|
// events from an in-memory channel into the EventStore. It must never block
|
|
|
|
|
// the broadcast hot path: when the queue is full, events are dropped and a
|
|
|
|
|
// counter is incremented. The reconnection handler tolerates gaps because the
|
|
|
|
|
// in-memory ring buffer remains the primary cold-start source for clients
|
|
|
|
|
// whose last_seq is recent.
|
2026-04-07 10:10:38 +02:00
|
|
|
|
2026-04-06 09:00:47 +00:00
|
|
|
package ws
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/telemetry"
|
2026-04-06 09:00:47 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// pendingEvent is a single event waiting to be flushed to the EventStore.
|
2026-04-06 09:29:29 +00:00
|
|
|
// seq carries the hub-assigned monotonic sequence so the row written to the
|
|
|
|
|
// store has the same seq as the wrapped payload sent to clients.
|
2026-04-06 09:00:47 +00:00
|
|
|
type pendingEvent struct {
|
2026-04-06 09:29:29 +00:00
|
|
|
seq int64
|
2026-04-06 09:00:47 +00:00
|
|
|
eventType string
|
|
|
|
|
channelID int64
|
|
|
|
|
payload []byte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EventPersister batches broadcast events and writes them to an EventStore.
|
|
|
|
|
type EventPersister struct {
|
2026-07-19 16:33:58 +00:00
|
|
|
store EventStore
|
2026-04-06 13:48:41 +00:00
|
|
|
queue chan pendingEvent
|
|
|
|
|
batchSize int
|
|
|
|
|
flushEvery time.Duration
|
2026-04-06 09:00:47 +00:00
|
|
|
|
2026-04-06 09:29:29 +00:00
|
|
|
startOnce sync.Once
|
|
|
|
|
started atomic.Bool
|
|
|
|
|
stopOnce sync.Once
|
|
|
|
|
stop chan struct{}
|
|
|
|
|
done chan struct{}
|
2026-08-07 21:20:48 +02:00
|
|
|
// stopCtxDone is the Done channel of the context passed to Stop. run's
|
|
|
|
|
// drain-on-stop loop reads it only after observing stop closed — the
|
|
|
|
|
// close/receive pair provides the happens-before, so there is no data
|
|
|
|
|
// race and no lock. A nil value (an uncancellable Stop ctx, e.g.
|
|
|
|
|
// context.Background) means "drain fully".
|
|
|
|
|
stopCtxDone <-chan struct{}
|
2026-04-06 09:00:47 +00:00
|
|
|
|
|
|
|
|
persisted atomic.Uint64
|
|
|
|
|
dropped atomic.Uint64
|
|
|
|
|
flushes atomic.Uint64
|
|
|
|
|
errors atomic.Uint64
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 13:48:41 +00:00
|
|
|
// NewEventPersister returns a persister wired to s. s MUST be non-nil —
|
|
|
|
|
// run() dereferences p.store on every flush, so a nil store would panic on
|
|
|
|
|
// the first tick. We fail fast here so the misconfiguration surfaces at
|
|
|
|
|
// construction time (main.go, tests) instead of minutes later in the
|
|
|
|
|
// background goroutine.
|
|
|
|
|
//
|
|
|
|
|
// queueSize sets the channel buffer; once full, Enqueue increments the
|
|
|
|
|
// dropped counter without blocking. batchSize and flushEvery control the
|
|
|
|
|
// flush triggers.
|
2026-07-19 16:33:58 +00:00
|
|
|
func NewEventPersister(s EventStore, queueSize, batchSize int, flushEvery time.Duration) *EventPersister {
|
2026-04-06 13:48:41 +00:00
|
|
|
if s == nil {
|
2026-07-19 16:33:58 +00:00
|
|
|
panic("ws: NewEventPersister requires a non-nil EventStore")
|
2026-04-06 13:48:41 +00:00
|
|
|
}
|
2026-04-06 09:00:47 +00:00
|
|
|
if queueSize <= 0 {
|
|
|
|
|
queueSize = 1024
|
|
|
|
|
}
|
|
|
|
|
if batchSize <= 0 {
|
|
|
|
|
batchSize = 50
|
|
|
|
|
}
|
|
|
|
|
if flushEvery <= 0 {
|
|
|
|
|
flushEvery = 100 * time.Millisecond
|
|
|
|
|
}
|
|
|
|
|
return &EventPersister{
|
2026-04-06 13:48:41 +00:00
|
|
|
store: s,
|
|
|
|
|
queue: make(chan pendingEvent, queueSize),
|
|
|
|
|
batchSize: batchSize,
|
|
|
|
|
flushEvery: flushEvery,
|
|
|
|
|
stop: make(chan struct{}),
|
|
|
|
|
done: make(chan struct{}),
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:29:29 +00:00
|
|
|
// Start launches the background flusher goroutine. Idempotent — calling
|
|
|
|
|
// Start more than once is a no-op so test setups that share a persister
|
|
|
|
|
// across cases don't spawn duplicate runners.
|
2026-04-06 09:00:47 +00:00
|
|
|
func (p *EventPersister) Start(ctx context.Context) {
|
2026-04-06 09:29:29 +00:00
|
|
|
p.startOnce.Do(func() {
|
|
|
|
|
p.started.Store(true)
|
|
|
|
|
go p.run(ctx)
|
|
|
|
|
})
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enqueue queues an event for persistence. Non-blocking; drops on full queue.
|
2026-04-06 09:29:29 +00:00
|
|
|
// seq is the hub-assigned monotonic sequence for this event — it must be the
|
|
|
|
|
// same value embedded in the wrapped payload so reconnect replay returns rows
|
|
|
|
|
// whose row-seq matches the payload-seq the client tracks.
|
2026-04-06 09:49:03 +00:00
|
|
|
//
|
|
|
|
|
// CONTRACT: payload must not be mutated by the caller after Enqueue returns.
|
|
|
|
|
// All current call sites pass a fresh slice from wrapWithSeq, so no defensive
|
|
|
|
|
// copy is taken here. This matters because Enqueue is invoked under the hub's
|
|
|
|
|
// seqMu lock and any per-call allocation directly serializes broadcast
|
|
|
|
|
// throughput.
|
2026-04-06 09:29:29 +00:00
|
|
|
func (p *EventPersister) Enqueue(seq int64, eventType string, channelID int64, payload []byte) {
|
2026-04-06 09:00:47 +00:00
|
|
|
if p == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-28 06:54:32 +02:00
|
|
|
// run() stops reading p.queue the instant it exits, but the channel keeps
|
|
|
|
|
// its buffer and keeps accepting sends — without this check a caller that
|
|
|
|
|
// enqueues after Stop has returned (e.g. hub.GracefulStop still
|
|
|
|
|
// broadcasting a server_restart notice after main.go's LIFO defers have
|
|
|
|
|
// already stopped this persister) would land silently in a dead channel:
|
|
|
|
|
// neither persisted nor counted as dropped. p.done closes only when
|
|
|
|
|
// run() has actually exited, so this is exact, not a best guess.
|
|
|
|
|
select {
|
|
|
|
|
case <-p.done:
|
|
|
|
|
p.dropped.Add(1)
|
|
|
|
|
slog.Error("event dropped: persister stopped",
|
|
|
|
|
"seq", seq,
|
|
|
|
|
"event_type", eventType,
|
|
|
|
|
"channel_id", channelID,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
}
|
2026-04-06 09:00:47 +00:00
|
|
|
select {
|
2026-04-06 09:49:03 +00:00
|
|
|
case p.queue <- pendingEvent{seq: seq, eventType: eventType, channelID: channelID, payload: payload}:
|
2026-04-06 09:00:47 +00:00
|
|
|
default:
|
|
|
|
|
p.dropped.Add(1)
|
2026-08-15 20:50:47 +02:00
|
|
|
// The WSEventsDropped OTel counter is synced from this atomic by
|
|
|
|
|
// run()'s ticker (which has a real context) — an instrumentation call
|
|
|
|
|
// here would sit under the caller's seqMu and trip contextcheck.
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 21:20:48 +02:00
|
|
|
// Stop signals the persister to drain remaining events and exit, and returns
|
|
|
|
|
// only after the run goroutine has fully exited (i.e. has stopped touching
|
|
|
|
|
// the store). This is the load-bearing contract: main.go closes the database
|
|
|
|
|
// right after Stop returns (LIFO defers), so Stop must guarantee no flush is
|
|
|
|
|
// still in flight — otherwise a late flush writes into a closed pool and
|
|
|
|
|
// events are lost. ctx does NOT abandon that wait; it only bounds how long
|
|
|
|
|
// run() keeps draining the queue before it stops accepting new entries, does
|
|
|
|
|
// one final flush, and exits (see run). A single stuck flush therefore
|
|
|
|
|
// delays shutdown by at most that flush rather than closing the DB
|
|
|
|
|
// underneath it.
|
|
|
|
|
//
|
|
|
|
|
// Safe to call without a prior Start: in that case there's no goroutine to
|
|
|
|
|
// wait for and Stop returns immediately after closing the stop channel.
|
2026-04-06 09:00:47 +00:00
|
|
|
func (p *EventPersister) Stop(ctx context.Context) {
|
|
|
|
|
if p == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-07 21:20:48 +02:00
|
|
|
p.stopOnce.Do(func() {
|
|
|
|
|
// Published before close(p.stop): run() reads stopCtxDone only after
|
|
|
|
|
// its receive on p.stop observes the close, and the close/receive
|
|
|
|
|
// pair makes this write visible without a data race.
|
|
|
|
|
p.stopCtxDone = ctx.Done()
|
|
|
|
|
close(p.stop)
|
|
|
|
|
})
|
2026-04-06 09:29:29 +00:00
|
|
|
if !p.started.Load() {
|
|
|
|
|
// run() was never launched, so done will never be closed.
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-07 21:20:48 +02:00
|
|
|
// Always wait for the goroutine to exit — never race it against ctx.
|
|
|
|
|
<-p.done
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stats returns lifetime counters.
|
|
|
|
|
func (p *EventPersister) Stats() (persisted, dropped, flushes, errs uint64) {
|
|
|
|
|
return p.persisted.Load(), p.dropped.Load(), p.flushes.Load(), p.errors.Load()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *EventPersister) run(ctx context.Context) {
|
|
|
|
|
defer close(p.done)
|
2026-04-06 13:48:41 +00:00
|
|
|
tick := time.NewTicker(p.flushEvery)
|
2026-04-06 09:00:47 +00:00
|
|
|
defer tick.Stop()
|
|
|
|
|
|
2026-04-06 09:49:03 +00:00
|
|
|
// Cache the AppMetrics bundle once instead of looking it up per event.
|
|
|
|
|
metrics := telemetry.NewAppMetrics()
|
|
|
|
|
|
2026-08-15 20:50:47 +02:00
|
|
|
// Enqueue only bumps the p.dropped atomic (it runs under the hub's seqMu
|
|
|
|
|
// with no context); this loop owns syncing the OTel counter from it.
|
|
|
|
|
var droppedReported uint64
|
|
|
|
|
syncDropped := func() {
|
|
|
|
|
if d := p.dropped.Load(); d > droppedReported {
|
|
|
|
|
metrics.WSEventsDropped.Add(ctx, int64(d-droppedReported)) //nolint:gosec // monotonic counter delta
|
|
|
|
|
droppedReported = d
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:00:47 +00:00
|
|
|
batch := make([]pendingEvent, 0, p.batchSize)
|
2026-07-31 15:41:57 +02:00
|
|
|
// Scratch slice reused across flushes for the store's batch shape.
|
|
|
|
|
rows := make([]db.PersistedEvent, 0, p.batchSize)
|
2026-04-06 09:00:47 +00:00
|
|
|
flush := func() {
|
|
|
|
|
if len(batch) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p.flushes.Add(1)
|
2026-07-31 15:41:57 +02:00
|
|
|
rows = rows[:0]
|
2026-04-06 09:00:47 +00:00
|
|
|
for _, evt := range batch {
|
2026-07-31 15:41:57 +02:00
|
|
|
rows = append(rows, db.PersistedEvent{
|
|
|
|
|
Seq: evt.seq,
|
|
|
|
|
EventType: evt.eventType,
|
|
|
|
|
ChannelID: evt.channelID,
|
|
|
|
|
Payload: evt.payload,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
// One transaction per flush instead of one autocommit write per event.
|
|
|
|
|
// PersistEvents keeps the best-effort contract: on tx failure it retries
|
|
|
|
|
// per-row so a single bad event doesn't drop the batch.
|
|
|
|
|
persisted, err := p.store.PersistEvents(ctx, rows)
|
|
|
|
|
if persisted > 0 {
|
|
|
|
|
p.persisted.Add(uint64(persisted))
|
|
|
|
|
metrics.WSEventsPersisted.Add(ctx, int64(persisted))
|
|
|
|
|
}
|
|
|
|
|
if failed := len(batch) - persisted; failed > 0 {
|
|
|
|
|
p.errors.Add(uint64(failed)) //nolint:gosec // failed is non-negative
|
|
|
|
|
metrics.WSEventsPersistErrors.Add(ctx, int64(failed))
|
|
|
|
|
slog.Warn("event persister: flush lost events",
|
|
|
|
|
"failed", failed, "batch", len(batch), "err", err)
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
batch = batch[:0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-p.stop:
|
2026-08-07 21:20:48 +02:00
|
|
|
// Drain anything still in the channel before exiting. The drain
|
|
|
|
|
// is bounded by the Stop context (p.stopCtxDone): once it fires
|
|
|
|
|
// we do one final flush and exit rather than keep pulling, so a
|
|
|
|
|
// slow store delays shutdown by at most one flush instead of
|
|
|
|
|
// unboundedly. Either way the goroutine finishes any in-flight
|
|
|
|
|
// flush before returning (and closing p.done), so Stop's caller
|
|
|
|
|
// never closes the store under a live flusher.
|
2026-04-06 09:00:47 +00:00
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case evt := <-p.queue:
|
|
|
|
|
batch = append(batch, evt)
|
|
|
|
|
if len(batch) >= p.batchSize {
|
|
|
|
|
flush()
|
|
|
|
|
}
|
2026-08-07 21:20:48 +02:00
|
|
|
case <-p.stopCtxDone:
|
|
|
|
|
flush()
|
|
|
|
|
return
|
2026-04-06 09:00:47 +00:00
|
|
|
default:
|
|
|
|
|
flush()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
flush()
|
|
|
|
|
return
|
|
|
|
|
case evt := <-p.queue:
|
|
|
|
|
batch = append(batch, evt)
|
|
|
|
|
if len(batch) >= p.batchSize {
|
|
|
|
|
flush()
|
|
|
|
|
}
|
|
|
|
|
case <-tick.C:
|
|
|
|
|
flush()
|
2026-08-15 20:50:47 +02:00
|
|
|
syncDropped()
|
2026-04-06 09:00:47 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|