830 lines
30 KiB
Go
830 lines
30 KiB
Go
package mtprotoedge
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRPCResultCacheFullSessionDoesNotBlockAnotherAuth(t *testing.T) {
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCacheWithFairCapacity(func() time.Time { return now }, rpcResultCacheCapacity{
|
|
maxPending: 8, maxPendingPerAuth: 6,
|
|
globalMaxEntries: 8, globalMaxBytes: 64,
|
|
authMaxEntries: 6, authMaxBytes: 48,
|
|
sessionMaxEntries: 2, sessionMaxBytes: 16,
|
|
})
|
|
authA := [8]byte{0xa1}
|
|
authB := [8]byte{0xb1}
|
|
const sessionA = int64(77)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
msgID := int64(1000 + i)
|
|
claim, err := cache.Acquire(authA, sessionA, msgID)
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("same-session admission %d = %#v, %v", i, claim, err)
|
|
}
|
|
cache.Put(authA, sessionA, msgID, &encodedOutboundMessage{body: []byte{1}})
|
|
}
|
|
if _, err := cache.Acquire(authA, sessionA, 2000); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("admission beyond session entry limit = %v, want capacity", err)
|
|
}
|
|
otherAuth, err := cache.Acquire(authB, 88, 3000)
|
|
if err != nil || otherAuth.state != rpcResultAcquireOwner {
|
|
t.Fatalf("other auth blocked by full session: %#v, %v", otherAuth, err)
|
|
}
|
|
if !otherAuth.owner.Abort() {
|
|
t.Fatal("other-auth owner did not abort")
|
|
}
|
|
if _, ok := cache.Get(authA, sessionA, 1000); !ok {
|
|
t.Fatal("session capacity pressure evicted an unexpired result")
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheFullAuthDoesNotBlockAnotherAuth(t *testing.T) {
|
|
cache := newRPCResultCacheWithFairCapacity(time.Now, rpcResultCacheCapacity{
|
|
maxPending: 8, maxPendingPerAuth: 4,
|
|
globalMaxEntries: 8, globalMaxBytes: 64,
|
|
authMaxEntries: 2, authMaxBytes: 32,
|
|
sessionMaxEntries: 2, sessionMaxBytes: 16,
|
|
})
|
|
authA := [8]byte{0xa2}
|
|
authB := [8]byte{0xb2}
|
|
for i := 0; i < 2; i++ {
|
|
claim, err := cache.Acquire(authA, int64(10+i), int64(100+i))
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("auth A admission %d = %#v, %v", i, claim, err)
|
|
}
|
|
cache.Put(authA, int64(10+i), int64(100+i), &encodedOutboundMessage{body: []byte{1}})
|
|
}
|
|
if _, err := cache.Acquire(authA, 12, 102); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("same-auth new session at auth limit = %v, want capacity", err)
|
|
}
|
|
other, err := cache.Acquire(authB, 20, 200)
|
|
if err != nil || other.state != rpcResultAcquireOwner {
|
|
t.Fatalf("other auth blocked by full auth A: %#v, %v", other, err)
|
|
}
|
|
other.owner.Abort()
|
|
}
|
|
|
|
func TestRPCResultCacheAuthAndSessionByteLimitsAreIndependent(t *testing.T) {
|
|
cache := newRPCResultCacheWithFairCapacity(time.Now, rpcResultCacheCapacity{
|
|
maxPending: 8, maxPendingPerAuth: 6,
|
|
globalMaxEntries: 10, globalMaxBytes: 10,
|
|
authMaxEntries: 8, authMaxBytes: 4,
|
|
sessionMaxEntries: 6, sessionMaxBytes: 2,
|
|
})
|
|
authA := [8]byte{0xa4}
|
|
authB := [8]byte{0xb4}
|
|
first, err := cache.Acquire(authA, 1, 101)
|
|
if err != nil || first.state != rpcResultAcquireOwner {
|
|
t.Fatalf("first owner = %#v, %v", first, err)
|
|
}
|
|
cache.Put(authA, 1, 101, &encodedOutboundMessage{body: []byte{1, 2}})
|
|
if _, err := cache.Acquire(authA, 1, 102); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("same session beyond byte limit = %v, want capacity", err)
|
|
}
|
|
second, err := cache.Acquire(authA, 2, 201)
|
|
if err != nil || second.state != rpcResultAcquireOwner {
|
|
t.Fatalf("second session owner = %#v, %v", second, err)
|
|
}
|
|
cache.Put(authA, 2, 201, &encodedOutboundMessage{body: []byte{3, 4}})
|
|
if _, err := cache.Acquire(authA, 3, 301); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("same auth beyond byte limit = %v, want capacity", err)
|
|
}
|
|
other, err := cache.Acquire(authB, 3, 302)
|
|
if err != nil || other.state != rpcResultAcquireOwner {
|
|
t.Fatalf("other auth blocked by auth A byte limit: %#v, %v", other, err)
|
|
}
|
|
other.owner.Abort()
|
|
}
|
|
|
|
func TestRPCResultCachePerAuthPendingLimitIsAdditional(t *testing.T) {
|
|
cache := newRPCResultCacheWithFairCapacity(time.Now, rpcResultCacheCapacity{
|
|
maxPending: 6, maxPendingPerAuth: 2,
|
|
globalMaxEntries: 12, globalMaxBytes: 64,
|
|
authMaxEntries: 6, authMaxBytes: 32,
|
|
sessionMaxEntries: 4, sessionMaxBytes: 16,
|
|
})
|
|
authA := [8]byte{0xa3}
|
|
authB := [8]byte{0xb3}
|
|
owners := make([]*rpcResultOwnerLease, 0, 3)
|
|
for i := 0; i < 2; i++ {
|
|
claim, err := cache.Acquire(authA, int64(i+1), int64(100+i))
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("pending auth A %d = %#v, %v", i, claim, err)
|
|
}
|
|
owners = append(owners, claim.owner)
|
|
}
|
|
if _, err := cache.Acquire(authA, 3, 103); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("third pending owner for auth A = %v, want capacity", err)
|
|
}
|
|
other, err := cache.Acquire(authB, 4, 104)
|
|
if err != nil || other.state != rpcResultAcquireOwner {
|
|
t.Fatalf("auth B blocked by auth A pending limit: %#v, %v", other, err)
|
|
}
|
|
owners = append(owners, other.owner)
|
|
for _, owner := range owners {
|
|
if !owner.Abort() {
|
|
t.Fatal("pending owner did not abort")
|
|
}
|
|
}
|
|
if usage := cache.fairBudget.authSnapshot(authA); usage != (rpcResultBudgetUsage{}) {
|
|
t.Fatalf("auth A budget after abort = %#v", usage)
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheFairReservationLifecycleReturnsEveryScope(t *testing.T) {
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCacheWithFairCapacity(func() time.Time { return now }, rpcResultCacheCapacity{
|
|
maxPending: 4, maxPendingPerAuth: 3,
|
|
globalMaxEntries: 6, globalMaxBytes: 10,
|
|
authMaxEntries: 5, authMaxBytes: 8,
|
|
sessionMaxEntries: 3, sessionMaxBytes: 6,
|
|
})
|
|
auth := [8]byte{0xc1}
|
|
|
|
aborted, err := cache.Acquire(auth, 1, 101)
|
|
if err != nil || aborted.state != rpcResultAcquireOwner {
|
|
t.Fatalf("aborted owner = %#v, %v", aborted, err)
|
|
}
|
|
if usage := cache.fairBudget.authSnapshot(auth); usage.entries != 1 || usage.bytes != 1 || usage.pending != 1 {
|
|
t.Fatalf("pending auth reservation = %#v", usage)
|
|
}
|
|
if !aborted.owner.Abort() {
|
|
t.Fatal("owner Abort lost")
|
|
}
|
|
if usage := cache.fairBudget.authSnapshot(auth); usage != (rpcResultBudgetUsage{}) {
|
|
t.Fatalf("Abort leaked auth reservation %#v", usage)
|
|
}
|
|
|
|
body, err := cache.Acquire(auth, 1, 102)
|
|
if err != nil || body.state != rpcResultAcquireOwner {
|
|
t.Fatalf("body owner = %#v, %v", body, err)
|
|
}
|
|
cache.Put(auth, 1, 102, &encodedOutboundMessage{body: make([]byte, 4)})
|
|
if usage := cache.fairBudget.sessionSnapshot(auth, 1); usage.entries != 1 || usage.bytes != 4 || usage.pending != 0 {
|
|
t.Fatalf("body session reservation = %#v", usage)
|
|
}
|
|
|
|
tombstone, err := cache.Acquire(auth, 2, 201)
|
|
if err != nil || tombstone.state != rpcResultAcquireOwner {
|
|
t.Fatalf("tombstone owner = %#v, %v", tombstone, err)
|
|
}
|
|
// This cannot fit the 10-byte global or 8-byte auth ceiling. Put must not
|
|
// panic or lose ownership; it transfers the one-byte token to a tombstone.
|
|
cache.Put(auth, 2, 201, &encodedOutboundMessage{body: make([]byte, 20)})
|
|
if usage := cache.fairBudget.sessionSnapshot(auth, 2); usage.entries != 1 || usage.bytes != 1 || usage.pending != 0 {
|
|
t.Fatalf("tombstone session reservation = %#v", usage)
|
|
}
|
|
if got := cache.completedEntries.snapshot(); got != 2 {
|
|
t.Fatalf("global entries after body+tombstone = %d, want 2", got)
|
|
}
|
|
if got := cache.completedBytes.snapshot(); got != 5 {
|
|
t.Fatalf("global bytes after body+tombstone = %d, want 5", got)
|
|
}
|
|
|
|
cache.Put(auth, 1, 102, &encodedOutboundMessage{body: make([]byte, 2)})
|
|
if got := cache.completedBytes.snapshot(); got != 3 {
|
|
t.Fatalf("replacement did not resize global bytes: %d", got)
|
|
}
|
|
now = now.Add(rpcResultCacheTTL + time.Second)
|
|
_, _ = cache.Get(auth, 1, 102)
|
|
_, _ = cache.Get(auth, 2, 201)
|
|
if got := cache.completedEntries.snapshot(); got != 0 {
|
|
t.Fatalf("TTL leaked global entries %d", got)
|
|
}
|
|
if got := cache.completedBytes.snapshot(); got != 0 {
|
|
t.Fatalf("TTL leaked global bytes %d", got)
|
|
}
|
|
if usage := cache.fairBudget.authSnapshot(auth); usage != (rpcResultBudgetUsage{}) {
|
|
t.Fatalf("TTL leaked auth reservation %#v", usage)
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheFullKeyMaphashSpreadsOneSession(t *testing.T) {
|
|
first := newRPCResultCacheWithFlightLimit(time.Now, 64)
|
|
second := newRPCResultCacheWithFlightLimit(time.Now, 64)
|
|
auth := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
|
|
const sessionID = int64(99)
|
|
seen := make(map[uint64]struct{})
|
|
differentInstance := false
|
|
for msgID := int64(1); msgID <= 256; msgID++ {
|
|
key := rpcResultCacheKey{authKeyID: auth, sessionID: sessionID, reqMsgID: msgID}
|
|
firstIndex := first.shardIndex(key)
|
|
seen[firstIndex] = struct{}{}
|
|
if firstIndex != second.shardIndex(key) {
|
|
differentInstance = true
|
|
}
|
|
}
|
|
if len(seen) < rpcResultCacheShards/2 {
|
|
t.Fatalf("one session used only %d/%d full-key shards", len(seen), rpcResultCacheShards)
|
|
}
|
|
if !differentInstance {
|
|
t.Fatal("two cache instances produced an identical shard stream; seed is not instance-random")
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheConcurrentFairReservationsNeverOvercommit(t *testing.T) {
|
|
cache := newRPCResultCacheWithFairCapacity(time.Now, rpcResultCacheCapacity{
|
|
maxPending: 24, maxPendingPerAuth: 4,
|
|
globalMaxEntries: 24, globalMaxBytes: 24,
|
|
authMaxEntries: 8, authMaxBytes: 8,
|
|
sessionMaxEntries: 3, sessionMaxBytes: 3,
|
|
})
|
|
const callers = 256
|
|
start := make(chan struct{})
|
|
var (
|
|
wg sync.WaitGroup
|
|
mu sync.Mutex
|
|
owners []*rpcResultOwnerLease
|
|
)
|
|
for i := 0; i < callers; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
<-start
|
|
auth := [8]byte{byte(i % 4)}
|
|
claim, err := cache.Acquire(auth, int64(i%8), int64(1000+i))
|
|
if errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
return
|
|
}
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Errorf("Acquire %d = %#v, %v", i, claim, err)
|
|
return
|
|
}
|
|
mu.Lock()
|
|
owners = append(owners, claim.owner)
|
|
mu.Unlock()
|
|
}(i)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
if got := cache.completedEntries.snapshot(); got > 24 || got != int64(len(owners)) {
|
|
t.Fatalf("global entry usage=%d owners=%d limit=24", got, len(owners))
|
|
}
|
|
if got := cache.completedBytes.snapshot(); got > 24 || got != int64(len(owners)) {
|
|
t.Fatalf("global byte usage=%d owners=%d limit=24", got, len(owners))
|
|
}
|
|
for i := 0; i < 4; i++ {
|
|
auth := [8]byte{byte(i)}
|
|
usage := cache.fairBudget.authSnapshot(auth)
|
|
if usage.entries > 8 || usage.bytes > 8 || usage.pending > 4 {
|
|
t.Fatalf("auth %d overcommitted: %#v", i, usage)
|
|
}
|
|
for sessionID := int64(0); sessionID < 8; sessionID++ {
|
|
session := cache.fairBudget.sessionSnapshot(auth, sessionID)
|
|
if session.entries > 3 || session.bytes > 3 {
|
|
t.Fatalf("auth %d session %d overcommitted: %#v", i, sessionID, session)
|
|
}
|
|
}
|
|
}
|
|
for _, owner := range owners {
|
|
if !owner.Abort() {
|
|
t.Fatal("concurrent owner did not abort")
|
|
}
|
|
}
|
|
if cache.completedEntries.snapshot() != 0 || cache.completedBytes.snapshot() != 0 {
|
|
t.Fatal("concurrent Abort leaked global fair budget")
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheConcurrentOwnerPublicationAcrossShards(t *testing.T) {
|
|
const publications = 256
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCacheWithFairCapacity(func() time.Time { return now }, rpcResultCacheCapacity{
|
|
maxPending: publications, maxPendingPerAuth: 4,
|
|
globalMaxEntries: publications, globalMaxBytes: publications * 4,
|
|
authMaxEntries: 4, authMaxBytes: 16,
|
|
sessionMaxEntries: 1, sessionMaxBytes: 4,
|
|
})
|
|
|
|
type publication struct {
|
|
auth [8]byte
|
|
session int64
|
|
msgID int64
|
|
owner *rpcResultOwnerLease
|
|
}
|
|
publicationsByKey := make([]publication, 0, publications)
|
|
for i := 0; i < publications; i++ {
|
|
auth := [8]byte{byte(i), byte(i >> 8), 0xa5}
|
|
sessionID := int64(10_000 + i)
|
|
msgID := int64(20_000 + i)
|
|
claim, err := cache.Acquire(auth, sessionID, msgID)
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("Acquire %d = %#v, %v", i, claim, err)
|
|
}
|
|
publicationsByKey = append(publicationsByKey, publication{
|
|
auth: auth, session: sessionID, msgID: msgID, owner: claim.owner,
|
|
})
|
|
}
|
|
|
|
start := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
for i := range publicationsByKey {
|
|
item := publicationsByKey[i]
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
if !item.owner.CompleteExecution(true) {
|
|
t.Errorf("CompleteExecution(%d) lost owner", item.msgID)
|
|
return
|
|
}
|
|
cache.Put(item.auth, item.session, item.msgID, &encodedOutboundMessage{body: []byte{1, 2, 3, 4}})
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
for _, item := range publicationsByKey {
|
|
encoded, ok := cache.Get(item.auth, item.session, item.msgID)
|
|
if !ok || encoded == nil || len(encoded.body) != 4 {
|
|
t.Fatalf("completed publication %d missing: ok=%v encoded=%#v", item.msgID, ok, encoded)
|
|
}
|
|
}
|
|
if got := cache.completedEntries.snapshot(); got != publications {
|
|
t.Fatalf("completed entries=%d, want %d", got, publications)
|
|
}
|
|
if got := cache.completedBytes.snapshot(); got != publications*4 {
|
|
t.Fatalf("completed bytes=%d, want %d", got, publications*4)
|
|
}
|
|
|
|
now = now.Add(rpcResultCacheTTL + time.Second)
|
|
cache.expireCompletedResults()
|
|
if cache.completedEntries.snapshot() != 0 || cache.completedBytes.snapshot() != 0 {
|
|
t.Fatal("parallel publications leaked fair-budget reservations after TTL")
|
|
}
|
|
}
|
|
|
|
func BenchmarkRPCResultCacheParallelShardPut(b *testing.B) {
|
|
cache := newRPCResultCacheWithFlightLimit(time.Now, rpcResultFlightDefaultMaxPending)
|
|
var nextWorker atomic.Uint64
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
b.RunParallel(func(pb *testing.PB) {
|
|
id := nextWorker.Add(1)
|
|
auth := [8]byte{
|
|
byte(id), byte(id >> 8), byte(id >> 16), byte(id >> 24),
|
|
byte(id >> 32), byte(id >> 40), byte(id >> 48), byte(id >> 56),
|
|
}
|
|
sessionID := int64(id)
|
|
msgID := int64(1_000_000 + id)
|
|
encoded := &encodedOutboundMessage{body: []byte{1, 2, 3, 4}}
|
|
for pb.Next() {
|
|
cache.Put(auth, sessionID, msgID, encoded)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRPCResultCacheEntryReservationTransfersAndReturns(t *testing.T) {
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCacheWithCapacity(func() time.Time { return now }, 4, 2, 2)
|
|
authKeyID := [8]byte{0xa2}
|
|
|
|
first, err := cache.Acquire(authKeyID, 1, 101)
|
|
if err != nil || first.state != rpcResultAcquireOwner || cache.completedEntries.snapshot() != 1 {
|
|
t.Fatalf("first pending reservation = %#v entries=%d err=%v", first, cache.completedEntries.snapshot(), err)
|
|
}
|
|
cache.Put(authKeyID, 1, 101, &encodedOutboundMessage{body: []byte{1}})
|
|
if got := cache.completedEntries.snapshot(); got != 1 {
|
|
t.Fatalf("pending -> body changed entry count to %d", got)
|
|
}
|
|
|
|
second, err := cache.Acquire(authKeyID, 2, 202)
|
|
if err != nil || second.state != rpcResultAcquireOwner || cache.completedEntries.snapshot() != 2 {
|
|
t.Fatalf("second pending reservation = %#v entries=%d err=%v", second, cache.completedEntries.snapshot(), err)
|
|
}
|
|
// The byte budget has only the second owner's one-byte token remaining.
|
|
// Publication therefore leaves an identity tombstone, which still owns its
|
|
// real process-wide entry slot.
|
|
cache.Put(authKeyID, 2, 202, &encodedOutboundMessage{body: []byte{2, 2, 2}})
|
|
if got := cache.completedEntries.snapshot(); got != 2 {
|
|
t.Fatalf("pending -> tombstone changed entry count to %d", got)
|
|
}
|
|
if _, err := cache.Acquire(authKeyID, 3, 303); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("third admission at entry limit = %v, want capacity", err)
|
|
}
|
|
|
|
now = now.Add(rpcResultCacheTTL + time.Second)
|
|
firstShard := cache.shardIndex(rpcResultCacheKey{authKeyID: authKeyID, sessionID: 1, reqMsgID: 101})
|
|
secondShard := cache.shardIndex(rpcResultCacheKey{authKeyID: authKeyID, sessionID: 2, reqMsgID: 202})
|
|
thirdMsgID := rpcResultTestMsgIDOutsideShards(t, cache, authKeyID, 3, 303, firstShard, secondShard)
|
|
third, err := cache.Acquire(authKeyID, 3, thirdMsgID)
|
|
if err != nil || third.state != rpcResultAcquireOwner {
|
|
t.Fatalf("admission after global expiry reap = %#v, %v", third, err)
|
|
}
|
|
if got := cache.completedEntries.snapshot(); got != 1 {
|
|
t.Fatalf("expired entries were not returned before new owner: %d", got)
|
|
}
|
|
if !third.owner.Abort() || cache.completedEntries.snapshot() != 0 {
|
|
t.Fatalf("Abort did not return entry reservation: entries=%d", cache.completedEntries.snapshot())
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheConcurrentGlobalEntryReservationNeverOvercommits(t *testing.T) {
|
|
const limit = 8
|
|
cache := newRPCResultCacheWithCapacity(time.Now, 128, 1<<20, limit)
|
|
authKeyID := [8]byte{0xa3}
|
|
var (
|
|
wg sync.WaitGroup
|
|
mu sync.Mutex
|
|
owners []*rpcResultOwnerLease
|
|
)
|
|
for i := 0; i < 64; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
claim, err := cache.Acquire(authKeyID, int64(i+1), int64(1000+i))
|
|
if err != nil {
|
|
if !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Errorf("Acquire %d: %v", i, err)
|
|
}
|
|
return
|
|
}
|
|
if claim.state != rpcResultAcquireOwner {
|
|
t.Errorf("Acquire %d state = %d", i, claim.state)
|
|
return
|
|
}
|
|
mu.Lock()
|
|
owners = append(owners, claim.owner)
|
|
mu.Unlock()
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
if len(owners) != limit || cache.completedEntries.snapshot() != limit {
|
|
t.Fatalf("concurrent owners=%d entries=%d, want %d", len(owners), cache.completedEntries.snapshot(), limit)
|
|
}
|
|
for _, owner := range owners {
|
|
if !owner.Abort() {
|
|
t.Fatal("reserved owner failed to abort")
|
|
}
|
|
}
|
|
if got := cache.completedEntries.snapshot(); got != 0 {
|
|
t.Fatalf("entry reservations after abort = %d", got)
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheRoundTripAndTTL(t *testing.T) {
|
|
if rpcResultCacheTTL != 331*time.Second {
|
|
t.Fatalf("replay TTL = %v, want full 300s past + 30s future window + 1s", rpcResultCacheTTL)
|
|
}
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCache(func() time.Time { return now })
|
|
|
|
var keyID [8]byte
|
|
keyID[0] = 0xab
|
|
encoded := &encodedOutboundMessage{body: []byte{1, 2, 3, 4}, typeID: 42, reqMsgID: 7}
|
|
|
|
if _, ok := cache.Get(keyID, 5, 7); ok {
|
|
t.Fatal("unexpected hit on empty cache")
|
|
}
|
|
cache.Put(keyID, 5, 7, encoded)
|
|
if got := cache.completedEntries.snapshot(); got != 1 {
|
|
t.Fatalf("direct Put entry reservation = %d, want 1", got)
|
|
}
|
|
if usage := cache.fairBudget.sessionSnapshot(keyID, 5); usage.entries != 1 || usage.bytes != 4 || usage.pending != 0 {
|
|
t.Fatalf("direct Put session reservation = %#v", usage)
|
|
}
|
|
|
|
got, ok := cache.Get(keyID, 5, 7)
|
|
if !ok {
|
|
t.Fatal("expected hit")
|
|
}
|
|
// encodedOutboundMessage 不可变契约下 Get/Put 共享指针,不做防御性拷贝。
|
|
if got != encoded {
|
|
t.Fatal("expected shared pointer, got clone")
|
|
}
|
|
|
|
// 不同 session / msg_id 不串。
|
|
if _, ok := cache.Get(keyID, 6, 7); ok {
|
|
t.Fatal("hit with wrong session id")
|
|
}
|
|
if _, ok := cache.Get(keyID, 5, 8); ok {
|
|
t.Fatal("hit with wrong msg id")
|
|
}
|
|
|
|
// TTL 过期。
|
|
now = now.Add(rpcResultCacheTTL + time.Second)
|
|
if _, ok := cache.Get(keyID, 5, 7); ok {
|
|
t.Fatal("expected expiry after TTL")
|
|
}
|
|
if got := cache.completedEntries.snapshot(); got != 0 {
|
|
t.Fatalf("direct Put expiry left %d entry reservations", got)
|
|
}
|
|
if usage := cache.fairBudget.authSnapshot(keyID); usage != (rpcResultBudgetUsage{}) {
|
|
t.Fatalf("direct Put expiry leaked auth reservation %#v", usage)
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheDuplicatePutPreservesCompletedExecutionMetadata(t *testing.T) {
|
|
cache := newRPCResultCacheWithFlightLimit(time.Now, 1)
|
|
keyID := [8]byte{1, 9, 8, 4}
|
|
const sessionID, reqMsgID = int64(11), int64(12)
|
|
claim, err := cache.Acquire(keyID, sessionID, reqMsgID)
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("Acquire owner = %#v, %v", claim, err)
|
|
}
|
|
if !claim.owner.CompleteExecution(true) || !claim.owner.HandOff() {
|
|
t.Fatal("complete owner metadata")
|
|
}
|
|
first := &encodedOutboundMessage{body: []byte{1, 2, 3, 4}, typeID: 42, reqMsgID: reqMsgID}
|
|
cache.Put(keyID, sessionID, reqMsgID, first)
|
|
second := &encodedOutboundMessage{body: []byte{5, 6, 7, 8}, typeID: 42, reqMsgID: reqMsgID}
|
|
cache.Put(keyID, sessionID, reqMsgID, second)
|
|
|
|
replay, err := cache.Acquire(keyID, sessionID, reqMsgID)
|
|
if err != nil || replay.state != rpcResultAcquireCompleted || replay.encoded != second ||
|
|
!replay.executionKnown || !replay.executionOK {
|
|
t.Fatalf("duplicate Put metadata = %#v, err=%v", replay, err)
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheShardCapacityNeverEvictsUnexpiredResult(t *testing.T) {
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCache(func() time.Time { return now })
|
|
|
|
var keyID [8]byte
|
|
firstKey := rpcResultCacheKey{authKeyID: keyID, sessionID: 1, reqMsgID: 100}
|
|
shard := cache.shard(firstKey)
|
|
shard.mu.Lock()
|
|
shard.maxEntries = 1
|
|
shard.mu.Unlock()
|
|
|
|
claim, err := cache.Acquire(keyID, 1, 100)
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("first admission = %#v, %v", claim, err)
|
|
}
|
|
first := &encodedOutboundMessage{body: []byte{1}}
|
|
cache.Put(keyID, 1, 100, first)
|
|
secondMsgID := rpcResultTestMsgIDForShard(t, cache, keyID, 1, 101, cache.shardIndex(firstKey))
|
|
if _, err := cache.Acquire(keyID, 1, secondMsgID); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("full-shard admission = %v, want capacity", err)
|
|
}
|
|
if got, ok := cache.Get(keyID, 1, 100); !ok || got != first {
|
|
t.Fatalf("unexpired first result was displaced: got=%p ok=%v", got, ok)
|
|
}
|
|
|
|
now = now.Add(rpcResultCacheTTL + time.Second)
|
|
claim, err = cache.Acquire(keyID, 1, secondMsgID)
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("admission after expiry = %#v, %v", claim, err)
|
|
}
|
|
claim.owner.Abort()
|
|
}
|
|
|
|
func TestRPCResultCacheGlobalByteCapacityNeverEvictsUnexpiredResults(t *testing.T) {
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCacheWithLimits(func() time.Time { return now }, 32, 10)
|
|
var keyID [8]byte
|
|
|
|
// Five two-byte results consume the global budget. The sixth admission must
|
|
// fail bounded; none of the retained results may be sacrificed for it.
|
|
for sessionID := int64(1); sessionID <= 5; sessionID++ {
|
|
claim, err := cache.Acquire(keyID, sessionID, 100+sessionID)
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("admission %d = %#v, %v", sessionID, claim, err)
|
|
}
|
|
cache.Put(keyID, sessionID, 100+sessionID, &encodedOutboundMessage{body: []byte{1, 2}})
|
|
}
|
|
if got := cache.completedBytes.snapshot(); got != 10 {
|
|
t.Fatalf("completed bytes at capacity = %d, want 10", got)
|
|
}
|
|
if _, err := cache.Acquire(keyID, 6, 106); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("byte-full admission = %v, want capacity", err)
|
|
}
|
|
for sessionID := int64(1); sessionID <= 5; sessionID++ {
|
|
if _, ok := cache.Get(keyID, sessionID, 100+sessionID); !ok {
|
|
t.Fatalf("unexpired result %d was evicted", sessionID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheByteBudgetReturnsOnReplaceExpiryAndCapacity(t *testing.T) {
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCacheWithLimits(func() time.Time { return now }, 32, 32)
|
|
var keyID [8]byte
|
|
|
|
cache.Put(keyID, 1, 101, &encodedOutboundMessage{body: make([]byte, 4)})
|
|
cache.Put(keyID, 1, 101, &encodedOutboundMessage{body: make([]byte, 7)})
|
|
if got := cache.completedBytes.snapshot(); got != 7 {
|
|
t.Fatalf("completed bytes after growing replacement = %d, want 7", got)
|
|
}
|
|
if usage := cache.fairBudget.sessionSnapshot(keyID, 1); usage.entries != 1 || usage.bytes != 7 {
|
|
t.Fatalf("replacement fair reservation after growth = %#v", usage)
|
|
}
|
|
cache.Put(keyID, 1, 101, &encodedOutboundMessage{body: make([]byte, 2)})
|
|
if got := cache.completedBytes.snapshot(); got != 2 {
|
|
t.Fatalf("completed bytes after shrinking replacement = %d, want 2", got)
|
|
}
|
|
if usage := cache.fairBudget.sessionSnapshot(keyID, 1); usage.entries != 1 || usage.bytes != 2 {
|
|
t.Fatalf("replacement fair reservation after shrink = %#v", usage)
|
|
}
|
|
|
|
now = now.Add(rpcResultCacheTTL + time.Second)
|
|
if _, ok := cache.Get(keyID, 1, 101); ok {
|
|
t.Fatal("replacement should expire")
|
|
}
|
|
if got := cache.completedBytes.snapshot(); got != 0 {
|
|
t.Fatalf("completed bytes after expiry = %d, want 0", got)
|
|
}
|
|
|
|
key := rpcResultCacheKey{authKeyID: keyID, sessionID: 2, reqMsgID: 201}
|
|
shard := cache.shard(key)
|
|
shard.mu.Lock()
|
|
shard.maxEntries = 1
|
|
shard.mu.Unlock()
|
|
claim, err := cache.Acquire(keyID, 2, 201)
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("entry-capacity first admission = %#v, %v", claim, err)
|
|
}
|
|
cache.Put(keyID, 2, 201, &encodedOutboundMessage{body: make([]byte, 3)})
|
|
secondMsgID := rpcResultTestMsgIDForShard(t, cache, keyID, 2, 202, cache.shardIndex(key))
|
|
if _, err := cache.Acquire(keyID, 2, secondMsgID); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("entry-capacity second admission = %v", err)
|
|
}
|
|
if got := cache.completedBytes.snapshot(); got != 3 {
|
|
t.Fatalf("completed bytes after capacity rejection = %d, want 3", got)
|
|
}
|
|
if _, ok := cache.Get(keyID, 2, 201); !ok {
|
|
t.Fatal("capacity rejection displaced the first result")
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCachePublicationOverflowLeavesReplayCapacityTombstone(t *testing.T) {
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCacheWithLimits(func() time.Time { return now }, 32, 4)
|
|
var keyID [8]byte
|
|
claim, err := cache.Acquire(keyID, 1, 101)
|
|
if err != nil || claim.state != rpcResultAcquireOwner {
|
|
t.Fatalf("owner admission = %#v, %v", claim, err)
|
|
}
|
|
claim.owner.CompleteExecution(true)
|
|
tooLarge := &encodedOutboundMessage{body: make([]byte, 5)}
|
|
cache.Put(keyID, 1, 101, tooLarge)
|
|
if got := cache.completedBytes.snapshot(); got != 1 {
|
|
t.Fatalf("tombstone bytes = %d, want 1", got)
|
|
}
|
|
if _, ok := cache.Get(keyID, 1, 101); ok {
|
|
t.Fatal("capacity tombstone must not masquerade as a replayable body")
|
|
}
|
|
if _, err := cache.Acquire(keyID, 1, 101); !errors.Is(err, ErrRPCResultFlightCapacity) {
|
|
t.Fatalf("duplicate after publication overflow = %v, want capacity", err)
|
|
}
|
|
|
|
now = now.Add(rpcResultCacheTTL + time.Second)
|
|
retry, err := cache.Acquire(keyID, 1, 101)
|
|
if err != nil || retry.state != rpcResultAcquireOwner {
|
|
t.Fatalf("admission after tombstone expiry = %#v, %v", retry, err)
|
|
}
|
|
retry.owner.Abort()
|
|
}
|
|
|
|
func TestRPCResultCacheByteCapacityReclaimsExpiredAcrossShards(t *testing.T) {
|
|
now := time.Unix(1000, 0)
|
|
cache := newRPCResultCacheWithLimits(func() time.Time { return now }, 32, 2)
|
|
var keyID [8]byte
|
|
|
|
first, err := cache.Acquire(keyID, 1, 101)
|
|
if err != nil || first.state != rpcResultAcquireOwner {
|
|
t.Fatalf("first admission = %#v, %v", first, err)
|
|
}
|
|
cache.Put(keyID, 1, 101, &encodedOutboundMessage{body: []byte{1, 2}})
|
|
if got := cache.completedBytes.snapshot(); got != 2 {
|
|
t.Fatalf("full budget = %d, want 2", got)
|
|
}
|
|
|
|
// Select a key in another full-key shard. Its failed one-byte reservation
|
|
// must trigger the cold-path global expiry reap before returning capacity.
|
|
now = now.Add(rpcResultCacheTTL + time.Second)
|
|
firstKey := rpcResultCacheKey{authKeyID: keyID, sessionID: 1, reqMsgID: 101}
|
|
secondMsgID := rpcResultTestMsgIDOutsideShard(t, cache, keyID, 2, 202, cache.shardIndex(firstKey))
|
|
second, err := cache.Acquire(keyID, 2, secondMsgID)
|
|
if err != nil || second.state != rpcResultAcquireOwner {
|
|
t.Fatalf("cross-shard admission after expiry = %#v, %v", second, err)
|
|
}
|
|
second.owner.Abort()
|
|
if got := cache.completedBytes.snapshot(); got != 0 {
|
|
t.Fatalf("bytes after expired reap and abort = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheServerOptionsPropagateFairLimits(t *testing.T) {
|
|
sessionBytes := int64(maxOutboundBodyBytes)
|
|
s := New(Options{
|
|
RPCGlobalMaxTasks: 6,
|
|
RPCResultCacheMaxEntries: 12,
|
|
RPCResultCacheMaxBytes: sessionBytes + 2048,
|
|
RPCResultCacheAuthMaxEntries: 8,
|
|
RPCResultCacheAuthMaxBytes: sessionBytes + 1024,
|
|
RPCResultCacheSessionMaxEntries: 4,
|
|
RPCResultCacheSessionMaxBytes: sessionBytes,
|
|
RPCResultPendingPerAuth: 3,
|
|
})
|
|
if s.rpcResults.completedEntries.max != 12 || s.rpcResults.completedBytes.max != sessionBytes+2048 {
|
|
t.Fatalf("global option propagation = %d/%d", s.rpcResults.completedEntries.max, s.rpcResults.completedBytes.max)
|
|
}
|
|
budget := s.rpcResults.fairBudget
|
|
if budget.authLimit.entries != 8 || budget.authLimit.bytes != sessionBytes+1024 ||
|
|
budget.sessionLimit.entries != 4 || budget.sessionLimit.bytes != sessionBytes || budget.pendingPerAuth != 3 {
|
|
t.Fatalf("fair option propagation = auth:%#v session:%#v pending:%d",
|
|
budget.authLimit, budget.sessionLimit, budget.pendingPerAuth)
|
|
}
|
|
}
|
|
|
|
func TestRPCResultCacheServerOptionsFailFast(t *testing.T) {
|
|
base := Options{
|
|
RPCGlobalMaxTasks: 6,
|
|
RPCResultCacheMaxEntries: 12,
|
|
RPCResultCacheMaxBytes: 64 << 20,
|
|
RPCResultCacheAuthMaxEntries: 8,
|
|
RPCResultCacheAuthMaxBytes: 32 << 20,
|
|
RPCResultCacheSessionMaxEntries: 4,
|
|
RPCResultCacheSessionMaxBytes: 16 << 20,
|
|
RPCResultPendingPerAuth: 3,
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*Options)
|
|
}{
|
|
{name: "entry hierarchy", mutate: func(o *Options) { o.RPCResultCacheAuthMaxEntries = 13 }},
|
|
{name: "body does not fit session", mutate: func(o *Options) { o.RPCResultCacheSessionMaxBytes = maxOutboundBodyBytes - 1 }},
|
|
{name: "pending hierarchy", mutate: func(o *Options) { o.RPCResultPendingPerAuth = 7 }},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
opts := base
|
|
test.mutate(&opts)
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("New accepted invalid rpc_result cache options")
|
|
}
|
|
}()
|
|
_ = New(opts)
|
|
})
|
|
}
|
|
}
|
|
|
|
func rpcResultTestMsgIDForShard(
|
|
t *testing.T,
|
|
cache *rpcResultCache,
|
|
authKeyID [8]byte,
|
|
sessionID, start int64,
|
|
target uint64,
|
|
) int64 {
|
|
t.Helper()
|
|
for msgID := start; msgID < start+1_000_000; msgID++ {
|
|
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: msgID}
|
|
if cache.shardIndex(key) == target {
|
|
return msgID
|
|
}
|
|
}
|
|
t.Fatal("failed to find rpc_result key for target shard")
|
|
return 0
|
|
}
|
|
|
|
func rpcResultTestMsgIDOutsideShard(
|
|
t *testing.T,
|
|
cache *rpcResultCache,
|
|
authKeyID [8]byte,
|
|
sessionID, start int64,
|
|
excluded uint64,
|
|
) int64 {
|
|
t.Helper()
|
|
for msgID := start; msgID < start+1_000_000; msgID++ {
|
|
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: msgID}
|
|
if cache.shardIndex(key) != excluded {
|
|
return msgID
|
|
}
|
|
}
|
|
t.Fatal("failed to find rpc_result key outside excluded shard")
|
|
return 0
|
|
}
|
|
|
|
func rpcResultTestMsgIDOutsideShards(
|
|
t *testing.T,
|
|
cache *rpcResultCache,
|
|
authKeyID [8]byte,
|
|
sessionID, start int64,
|
|
excluded ...uint64,
|
|
) int64 {
|
|
t.Helper()
|
|
for msgID := start; msgID < start+1_000_000; msgID++ {
|
|
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: msgID}
|
|
index := cache.shardIndex(key)
|
|
allowed := true
|
|
for _, blocked := range excluded {
|
|
if index == blocked {
|
|
allowed = false
|
|
break
|
|
}
|
|
}
|
|
if allowed {
|
|
return msgID
|
|
}
|
|
}
|
|
t.Fatal("failed to find rpc_result key outside excluded shards")
|
|
return 0
|
|
}
|