202 lines
6.7 KiB
Go
202 lines
6.7 KiB
Go
package rpc
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
func waitPresenceWork(t *testing.T, r *Router, want PresenceWorkSnapshot) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
got := r.PresenceWorkSnapshot()
|
|
if got == want {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("presence work = %+v, want %+v", got, want)
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestPresenceWorkRunningCallbackSurvivesReplacementAndCancel(t *testing.T) {
|
|
r := &Router{presence: newPresenceTracker()}
|
|
started, release := make(chan struct{}), make(chan struct{})
|
|
r.presence.armOfflineTimer(7, 0, func() { close(started); <-release })
|
|
select {
|
|
case <-started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("callback did not start")
|
|
}
|
|
defer func() {
|
|
close(release)
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{})
|
|
}()
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{RunningCallbacks: 1})
|
|
r.presence.armOfflineTimer(7, time.Hour, func() { t.Error("canceled replacement fired") })
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{WaitingTimers: 1, RunningCallbacks: 1})
|
|
r.presence.cancelOfflineTimer(7)
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{RunningCallbacks: 1})
|
|
}
|
|
|
|
func TestPresenceWorkWaitingCancellationAndOtherDevice(t *testing.T) {
|
|
r := &Router{presence: newPresenceTracker()}
|
|
key := presenceSessionKey{sessionID: 2}
|
|
r.presence.setSessionStatus(key, 7, domain.UserStatus{Kind: domain.UserStatusOnline, Expires: 100})
|
|
r.SessionOfflineAt([8]byte{}, 1, 7, false, 50)
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{})
|
|
if _, ok := r.presence.statusFor(7, 60); !ok {
|
|
t.Fatal("departure erased the other device")
|
|
}
|
|
r.presence.armOfflineTimer(7, time.Hour, func() { t.Error("canceled timer fired") })
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{WaitingTimers: 1})
|
|
r.presence.cancelOfflineTimer(7)
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{})
|
|
}
|
|
|
|
type heldPresenceBatch struct {
|
|
started chan struct{}
|
|
release chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (s *heldPresenceBatch) UpdateLastSeenBatch(ctx context.Context, _ []store.UserLastSeenUpdate) error {
|
|
s.once.Do(func() { close(s.started) })
|
|
select {
|
|
case <-s.release:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func TestPresenceWorkCallbackToBatchHasNoUnobservedHandoff(t *testing.T) {
|
|
u := &heldPresenceBatch{started: make(chan struct{}), release: make(chan struct{})}
|
|
metrics := &capturePresenceLastSeenMetrics{}
|
|
d := newPresenceLastSeenBatchDispatcher(u, presenceLastSeenBatchConfig{MaxSize: 1}, zap.NewNop(), metrics)
|
|
r := &Router{presence: newPresenceTracker(), lastSeenBatch: d}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { d.Run(ctx); close(done) }()
|
|
defer func() { close(u.release); cancel(); <-done }()
|
|
callbackStarted, submit := make(chan struct{}), make(chan struct{})
|
|
r.presence.armOfflineTimer(7, 0, func() {
|
|
close(callbackStarted)
|
|
<-submit
|
|
if err := d.submit(store.UserLastSeenUpdate{UserID: 7, LastSeenAt: 99}); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
<-callbackStarted
|
|
var observing atomic.Bool
|
|
observing.Store(true)
|
|
snapshotsDone := make(chan struct{})
|
|
go func() {
|
|
defer close(snapshotsDone)
|
|
for observing.Load() {
|
|
s := r.PresenceWorkSnapshot()
|
|
if s.RunningCallbacks+s.PendingLastSeen != 1 && s.RunningCallbacks+s.PendingLastSeen != 2 {
|
|
t.Errorf("unobserved callback handoff: %+v", s)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
close(submit)
|
|
select {
|
|
case <-u.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("batch did not begin")
|
|
}
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{PendingLastSeen: 1})
|
|
observing.Store(false)
|
|
<-snapshotsDone
|
|
if d.pending.Load() != metrics.pending.Load() || metrics.pending.Load() != 1 {
|
|
t.Fatal("snapshot and exported batch pending disagree")
|
|
}
|
|
}
|
|
|
|
type heldDirectLastSeen struct{ started, release chan struct{} }
|
|
|
|
func (u heldDirectLastSeen) UpdateLastSeen(ctx context.Context, _ int64, _ int) error {
|
|
close(u.started)
|
|
select {
|
|
case <-u.release:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func TestPresenceWorkDirectWriteRemainsVisibleAfterParentReturns(t *testing.T) {
|
|
u := heldDirectLastSeen{make(chan struct{}), make(chan struct{})}
|
|
r := &Router{presence: newPresenceTracker(), log: zap.NewNop()}
|
|
r.persistReservedLastSeenAsync(context.Background(), u, 7, 99)
|
|
<-u.started
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{DirectWrites: 1})
|
|
close(u.release)
|
|
waitPresenceWork(t, r, PresenceWorkSnapshot{})
|
|
}
|
|
|
|
func TestPresenceWorkConcurrentArmCancel(t *testing.T) {
|
|
p := newPresenceTracker()
|
|
var workers sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
workers.Go(func() {
|
|
for j := 0; j < 100; j++ {
|
|
p.armOfflineTimer(int64(j%4+1), time.Hour, func() { t.Error("canceled timer fired") })
|
|
p.cancelOfflineTimer(int64(j%4 + 1))
|
|
}
|
|
})
|
|
}
|
|
workers.Wait()
|
|
for user := int64(1); user <= 4; user++ {
|
|
p.cancelOfflineTimer(user)
|
|
}
|
|
waitPresenceWork(t, &Router{presence: p}, PresenceWorkSnapshot{})
|
|
}
|
|
|
|
func TestPresenceWorkBatchRetryDrainAndOverflowAccounting(t *testing.T) {
|
|
u := &capturePresenceLastSeenUpdater{failFirst: 1, called: make(chan struct{}, 8)}
|
|
m := &capturePresenceLastSeenMetrics{}
|
|
d := newPresenceLastSeenBatchDispatcher(u, presenceLastSeenBatchConfig{MaxSize: 1, QueueSize: 1, DrainTimeout: time.Second}, zap.NewNop(), m)
|
|
if err := d.submit(store.UserLastSeenUpdate{UserID: 7, LastSeenAt: 99}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := d.submit(store.UserLastSeenUpdate{UserID: 8, LastSeenAt: 100}); !errors.Is(err, errPresenceLastSeenBatchFull) {
|
|
t.Fatalf("overflow = %v", err)
|
|
}
|
|
if d.pending.Load() != 1 || m.pending.Load() != 1 {
|
|
t.Fatal("rejected work changed pending")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
d.Run(ctx)
|
|
if d.pending.Load() != 0 || m.pending.Load() != 0 || m.failures.Load() != 1 || m.dropped.Load() != 0 {
|
|
t.Fatalf("drain accounting: pending=%d metric=%d failures=%d dropped=%d", d.pending.Load(), m.pending.Load(), m.failures.Load(), m.dropped.Load())
|
|
}
|
|
}
|
|
|
|
func TestPresenceWorkFailedDrainIsCountedNotSuccessful(t *testing.T) {
|
|
u := &capturePresenceLastSeenUpdater{failFirst: 1000, called: make(chan struct{}, 8)}
|
|
m := &capturePresenceLastSeenMetrics{}
|
|
d := newPresenceLastSeenBatchDispatcher(u, presenceLastSeenBatchConfig{MaxSize: 1, DrainTimeout: 10 * time.Millisecond}, zap.NewNop(), m)
|
|
if err := d.submit(store.UserLastSeenUpdate{UserID: 7, LastSeenAt: 99}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
d.Run(ctx)
|
|
if d.pending.Load() != 0 || m.pending.Load() != 0 || m.dropped.Load() != 1 || m.failures.Load() < 1 {
|
|
t.Fatalf("failed drain accounting: pending=%d metric=%d failures=%d dropped=%d", d.pending.Load(), m.pending.Load(), m.failures.Load(), m.dropped.Load())
|
|
}
|
|
}
|