291 lines
7.1 KiB
Go
291 lines
7.1 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
const (
|
|
defaultPresenceLastSeenBatchMax = 512
|
|
defaultPresenceLastSeenBatchWait = time.Second
|
|
defaultPresenceLastSeenBatchQueue = 65_536
|
|
defaultPresenceLastSeenBatchTimeout = 5 * time.Second
|
|
defaultPresenceLastSeenShutdownDrain = 10 * time.Second
|
|
presenceLastSeenRetryInitial = 50 * time.Millisecond
|
|
presenceLastSeenRetryMaximum = 2 * time.Second
|
|
)
|
|
|
|
var (
|
|
errPresenceLastSeenBatchFull = errors.New("presence last-seen batch queue full")
|
|
errPresenceLastSeenBatchStopped = errors.New("presence last-seen batch dispatcher stopped")
|
|
)
|
|
|
|
type presenceLastSeenBatchUpdater interface {
|
|
UpdateLastSeenBatch(ctx context.Context, updates []store.UserLastSeenUpdate) error
|
|
}
|
|
|
|
type presenceLastSeenBatchConfig struct {
|
|
MaxSize int
|
|
MaxWait time.Duration
|
|
QueueSize int
|
|
QueryTimeout time.Duration
|
|
DrainTimeout time.Duration
|
|
}
|
|
|
|
func normalizePresenceLastSeenBatchConfig(cfg presenceLastSeenBatchConfig) presenceLastSeenBatchConfig {
|
|
if cfg.MaxSize <= 0 {
|
|
cfg.MaxSize = defaultPresenceLastSeenBatchMax
|
|
}
|
|
if cfg.MaxWait <= 0 {
|
|
cfg.MaxWait = defaultPresenceLastSeenBatchWait
|
|
}
|
|
if cfg.QueueSize <= 0 {
|
|
cfg.QueueSize = defaultPresenceLastSeenBatchQueue
|
|
}
|
|
if cfg.QueryTimeout <= 0 {
|
|
cfg.QueryTimeout = defaultPresenceLastSeenBatchTimeout
|
|
}
|
|
if cfg.DrainTimeout <= 0 {
|
|
cfg.DrainTimeout = defaultPresenceLastSeenShutdownDrain
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// presenceLastSeenBatchDispatcher is the single production writer for
|
|
// asynchronous lifecycle last-seen watermarks. Accepted work is retried with a
|
|
// bounded backoff and remains in memory until the database write plus exact
|
|
// cache invalidation succeeds, or the bounded shutdown drain expires.
|
|
type presenceLastSeenBatchDispatcher struct {
|
|
updater presenceLastSeenBatchUpdater
|
|
cfg presenceLastSeenBatchConfig
|
|
queue chan store.UserLastSeenUpdate
|
|
log *zap.Logger
|
|
metrics Metrics
|
|
|
|
gate sync.RWMutex
|
|
accepting bool
|
|
}
|
|
|
|
func newPresenceLastSeenBatchDispatcher(
|
|
updater presenceLastSeenBatchUpdater,
|
|
cfg presenceLastSeenBatchConfig,
|
|
log *zap.Logger,
|
|
metrics Metrics,
|
|
) *presenceLastSeenBatchDispatcher {
|
|
if updater == nil {
|
|
return nil
|
|
}
|
|
cfg = normalizePresenceLastSeenBatchConfig(cfg)
|
|
if log == nil {
|
|
log = zap.NewNop()
|
|
}
|
|
if metrics == nil {
|
|
metrics = NopMetrics{}
|
|
}
|
|
return &presenceLastSeenBatchDispatcher{
|
|
updater: updater,
|
|
cfg: cfg,
|
|
queue: make(chan store.UserLastSeenUpdate, cfg.QueueSize),
|
|
log: log,
|
|
metrics: metrics,
|
|
accepting: true,
|
|
}
|
|
}
|
|
|
|
func (d *presenceLastSeenBatchDispatcher) submit(update store.UserLastSeenUpdate) error {
|
|
if d == nil || update.UserID == 0 || update.LastSeenAt <= 0 {
|
|
return nil
|
|
}
|
|
d.gate.RLock()
|
|
defer d.gate.RUnlock()
|
|
if !d.accepting {
|
|
return errPresenceLastSeenBatchStopped
|
|
}
|
|
d.metrics.PresenceLastSeenPending(1)
|
|
select {
|
|
case d.queue <- update:
|
|
d.metrics.PresenceLastSeenSubmitted()
|
|
return nil
|
|
default:
|
|
d.metrics.PresenceLastSeenPending(-1)
|
|
d.metrics.PresenceLastSeenOverflow()
|
|
return errPresenceLastSeenBatchFull
|
|
}
|
|
}
|
|
|
|
func (d *presenceLastSeenBatchDispatcher) stopAccepting() {
|
|
if d == nil {
|
|
return
|
|
}
|
|
d.gate.Lock()
|
|
d.accepting = false
|
|
d.gate.Unlock()
|
|
}
|
|
|
|
func (d *presenceLastSeenBatchDispatcher) Run(ctx context.Context) {
|
|
if d == nil {
|
|
return
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
for {
|
|
select {
|
|
case first := <-d.queue:
|
|
batch, canceled := d.collect(ctx, first)
|
|
if canceled || !d.executeWithRetry(ctx, batch) {
|
|
d.stopAccepting()
|
|
d.drain(batch)
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
d.stopAccepting()
|
|
d.drain(nil)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *presenceLastSeenBatchDispatcher) collect(
|
|
ctx context.Context,
|
|
first store.UserLastSeenUpdate,
|
|
) ([]store.UserLastSeenUpdate, bool) {
|
|
batch := make([]store.UserLastSeenUpdate, 0, d.cfg.MaxSize)
|
|
batch = append(batch, first)
|
|
if len(batch) >= d.cfg.MaxSize {
|
|
return batch, false
|
|
}
|
|
timer := time.NewTimer(d.cfg.MaxWait)
|
|
defer func() {
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
for len(batch) < d.cfg.MaxSize {
|
|
select {
|
|
case update := <-d.queue:
|
|
batch = append(batch, update)
|
|
case <-timer.C:
|
|
return batch, false
|
|
case <-ctx.Done():
|
|
return batch, true
|
|
}
|
|
}
|
|
return batch, false
|
|
}
|
|
|
|
func mergePresenceLastSeenBatch(updates []store.UserLastSeenUpdate) []store.UserLastSeenUpdate {
|
|
latest := make(map[int64]int, len(updates))
|
|
for _, update := range updates {
|
|
if update.UserID == 0 || update.LastSeenAt <= 0 {
|
|
continue
|
|
}
|
|
if current := latest[update.UserID]; update.LastSeenAt > current {
|
|
latest[update.UserID] = update.LastSeenAt
|
|
}
|
|
}
|
|
userIDs := make([]int64, 0, len(latest))
|
|
for userID := range latest {
|
|
userIDs = append(userIDs, userID)
|
|
}
|
|
sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] })
|
|
merged := make([]store.UserLastSeenUpdate, 0, len(userIDs))
|
|
for _, userID := range userIDs {
|
|
merged = append(merged, store.UserLastSeenUpdate{UserID: userID, LastSeenAt: latest[userID]})
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func (d *presenceLastSeenBatchDispatcher) executeWithRetry(
|
|
ctx context.Context,
|
|
updates []store.UserLastSeenUpdate,
|
|
) bool {
|
|
rawCount := len(updates)
|
|
updates = mergePresenceLastSeenBatch(updates)
|
|
if len(updates) == 0 {
|
|
return true
|
|
}
|
|
backoff := presenceLastSeenRetryInitial
|
|
for attempt := 1; ; attempt++ {
|
|
queryCtx, cancel := context.WithTimeout(ctx, d.cfg.QueryTimeout)
|
|
started := time.Now()
|
|
err := d.updater.UpdateLastSeenBatch(queryCtx, updates)
|
|
cancel()
|
|
d.metrics.PresenceLastSeenBatch(len(updates), time.Since(started), err)
|
|
if err == nil {
|
|
d.metrics.PresenceLastSeenPending(-rawCount)
|
|
return true
|
|
}
|
|
if attempt == 1 || attempt&(attempt-1) == 0 {
|
|
d.log.Warn("presence last-seen batch failed; retrying",
|
|
zap.Int("updates", len(updates)),
|
|
zap.Int("attempt", attempt),
|
|
zap.Duration("retry_after", backoff),
|
|
zap.Error(err))
|
|
}
|
|
timer := time.NewTimer(backoff)
|
|
select {
|
|
case <-timer.C:
|
|
case <-ctx.Done():
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
backoff *= 2
|
|
if backoff > presenceLastSeenRetryMaximum {
|
|
backoff = presenceLastSeenRetryMaximum
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *presenceLastSeenBatchDispatcher) drain(current []store.UserLastSeenUpdate) {
|
|
drainCtx, cancel := context.WithTimeout(context.Background(), d.cfg.DrainTimeout)
|
|
defer cancel()
|
|
pending := append([]store.UserLastSeenUpdate(nil), current...)
|
|
for {
|
|
for len(pending) < d.cfg.MaxSize {
|
|
select {
|
|
case update := <-d.queue:
|
|
pending = append(pending, update)
|
|
default:
|
|
break
|
|
}
|
|
if len(d.queue) == 0 {
|
|
break
|
|
}
|
|
}
|
|
if len(pending) > 0 {
|
|
if !d.executeWithRetry(drainCtx, pending) {
|
|
d.reportDrainDropped(len(pending) + len(d.queue))
|
|
return
|
|
}
|
|
pending = pending[:0]
|
|
}
|
|
if len(d.queue) == 0 {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *presenceLastSeenBatchDispatcher) reportDrainDropped(count int) {
|
|
if count <= 0 {
|
|
return
|
|
}
|
|
d.metrics.PresenceLastSeenDrainDropped(count)
|
|
d.metrics.PresenceLastSeenPending(-count)
|
|
d.log.Error("presence last-seen shutdown drain expired", zap.Int("updates", count))
|
|
}
|