owpengram-server/internal/mtprotoedge/rpc_result_cache.go

670 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mtprotoedge
import (
"container/list"
"context"
"encoding/binary"
"hash/maphash"
"sync"
"sync/atomic"
"time"
)
const (
// Telegram accepts client msg_id values up to five minutes old and up to
// thirty seconds in the future. Retain the result across that complete
// replay horizon, plus one second for boundary/scheduler jitter, so a valid
// duplicate cannot rerun its handler merely because our cache expired first.
rpcResultCacheTTL = 331 * time.Second
// Completed results cover the complete replay horizon under explicit global,
// auth and session hard ceilings. At the default 331-second TTL, the 1<<18
// global entries permit about 792 unique RPC/s process-wide before bounded
// backpressure; lower scopes provide noisy-neighbor isolation.
rpcResultCacheMaxEntries = 1 << 18
rpcResultCacheMaxBytes = 64 << 20
rpcResultCacheAuthMaxEntries = 1 << 15
rpcResultCacheAuthMaxBytes = 32 << 20
rpcResultCacheSessionMaxEntries = 1 << 14
rpcResultCacheSessionMaxBytes = 16 << 20
rpcResultFlightMaxPendingPerAuth = 1 << 11
// rpcResultCacheShards hashes the complete replay identity with a random
// per-instance maphash seed. Including req_msg_id spreads one hot session's
// independent requests instead of forcing them through one mutex. The shard
// count is a power of two.
rpcResultCacheShards = 16
)
type rpcResultCacheKey struct {
authKeyID [8]byte
sessionID int64
reqMsgID int64
}
type rpcResultCacheEntry struct {
key rpcResultCacheKey
// encoded is used only by legacy focused constructors whose cache has no
// SessionManager. Production rows are metadata receipts and always leave it
// nil; exact bytes have one owner in the logical-session outbox.
encoded *encodedOutboundMessage
size int
expiresAt time.Time
identity rpcResultRequestIdentity
admissionSeq uint64
executionKnown bool
executionOK bool
// acknowledged exists only for the ACK-before-Put race. A completed row is
// removed immediately on ACK; unlike the previous design, success receipts
// are not retained for the rest of the 331-second horizon.
acknowledged bool
// capacity marks a bounded replay tombstone. The original owner and its
// already-joined waiters received encoded, but the byte budget could not
// retain that body. Keeping the immutable identity until TTL prevents a
// duplicate from rerunning business; Acquire returns a capacity error.
capacity bool
// reservation is the same global+auth+session ownership acquired before the
// handler ran. Put transfers it from the pending flight; TTL returns it.
reservation *rpcResultBudgetReservation
}
type rpcResultDependency struct {
waiter *rpcResultWaiter
completed bool
success bool
}
// rpcResultCache 缓存已有交付证明的 rpc_result按 auth_key+session+req_msg_id
// 用于跨连接重放重复请求。Put 的调用方必须先证明结果已物理写出,或原 logical Conn
// 已不可逆 fenced绝不能发布“Conn 仍 current/open 但结果尚未上 wire”的完成态。
// encodedOutboundMessage 构造后不可变push fan-out 与 pending resend 均依赖该契约),
// 因此 Get/Put 直接共享指针,不做防御性拷贝。
type rpcResultCache struct {
shards [rpcResultCacheShards]rpcResultCacheShard
hashSeed maphash.Seed
completedBytes rpcResultCacheByteBudget
completedEntries rpcResultFlightLimit
fairBudget *rpcResultFairBudget
flightLimit rpcResultFlightLimit
subscriberBudget *rpcResultSubscriberBudget
subscriberPerFlight int
// sessions is the production payload source. Completed ledger rows retain
// only request identity/outcome/TTL metadata; exact bytes stay solely in the
// logical-session unacked outbox. Nil is kept as a legacy unit-test seam.
sessions *SessionManager
// nextAdmissionSeq is the process-wide ordering authority for auth-key
// shared Layer defaults. Exact owners allocate once; joins/replays retain the
// owner's value from their flight/completed descriptor.
nextAdmissionSeq atomic.Uint64
activeAdmissions rpcAdmissionTracker
}
func (c *rpcResultCache) stableAdmissionSafeFloor() uint64 {
if c == nil {
return 0
}
return c.activeAdmissions.stableSafeFloor(&c.nextAdmissionSeq)
}
type rpcResultCacheShard struct {
mu sync.Mutex
now func() time.Time
ttl time.Duration
// maxEntries is a focused-test seam for one physical shard. Production leaves
// it zero and uses the explicit global/auth/session fair-budget hierarchy.
maxEntries int
bytes int
order *list.List
byKey map[rpcResultCacheKey]*list.Element
// pending is deliberately independent from the completed-result order/byKey
// cache. In-flight owners and waiters must not disappear when completed
// results expire or are trimmed under entry/byte pressure.
pending map[rpcResultCacheKey]*rpcResultFlight
}
func newRPCResultCacheWithFlightLimit(now func() time.Time, maxPending int) *rpcResultCache {
if maxPending <= 0 {
maxPending = rpcResultFlightDefaultMaxPending
}
pendingPerAuth := rpcResultFlightMaxPendingPerAuth
if pendingPerAuth > maxPending {
pendingPerAuth = maxPending
}
return newRPCResultCacheWithFairCapacity(now, rpcResultCacheCapacity{
maxPending: maxPending,
maxPendingPerAuth: pendingPerAuth,
globalMaxBytes: rpcResultCacheMaxBytes,
globalMaxEntries: rpcResultCacheMaxEntries,
authMaxBytes: rpcResultCacheAuthMaxBytes,
authMaxEntries: rpcResultCacheAuthMaxEntries,
sessionMaxBytes: rpcResultCacheSessionMaxBytes,
sessionMaxEntries: rpcResultCacheSessionMaxEntries,
})
}
func newRPCResultCacheWithLimits(now func() time.Time, maxPending, maxCompletedBytes int) *rpcResultCache {
return newRPCResultCacheWithCapacity(now, maxPending, int64(maxCompletedBytes), rpcResultCacheMaxEntries)
}
func newRPCResultCacheWithCapacity(
now func() time.Time,
maxPending int,
maxCompletedBytes int64,
maxCompletedEntries int,
) *rpcResultCache {
// Compatibility/test constructor: the caller supplied only global limits, so
// keep every fairness scope equal to that global ceiling. Production always
// calls newRPCResultCacheWithFairCapacity with explicit auth/session limits.
return newRPCResultCacheWithFairCapacity(now, rpcResultCacheCapacity{
maxPending: maxPending,
maxPendingPerAuth: maxPending,
globalMaxBytes: maxCompletedBytes,
globalMaxEntries: maxCompletedEntries,
authMaxBytes: maxCompletedBytes,
authMaxEntries: maxCompletedEntries,
sessionMaxBytes: maxCompletedBytes,
sessionMaxEntries: maxCompletedEntries,
})
}
type rpcResultCacheCapacity struct {
maxPending int
maxPendingPerAuth int
globalMaxBytes int64
globalMaxEntries int
authMaxBytes int64
authMaxEntries int
sessionMaxBytes int64
sessionMaxEntries int
subscriberMaxGlobal int
subscriberMaxAuth int
subscriberMaxSession int
subscriberMaxPerFlight int
sessions *SessionManager
}
func newRPCResultCacheWithFairCapacity(now func() time.Time, capacity rpcResultCacheCapacity) *rpcResultCache {
if now == nil {
now = time.Now
}
if capacity.maxPending <= 0 {
capacity.maxPending = rpcResultFlightDefaultMaxPending
}
if capacity.maxPendingPerAuth <= 0 {
capacity.maxPendingPerAuth = capacity.maxPending
}
if capacity.globalMaxBytes <= 0 {
capacity.globalMaxBytes = rpcResultCacheMaxBytes
}
if capacity.globalMaxEntries <= 0 {
capacity.globalMaxEntries = rpcResultCacheMaxEntries
}
if capacity.authMaxBytes <= 0 {
capacity.authMaxBytes = capacity.globalMaxBytes
}
if capacity.authMaxEntries <= 0 {
capacity.authMaxEntries = capacity.globalMaxEntries
}
if capacity.sessionMaxBytes <= 0 {
capacity.sessionMaxBytes = capacity.authMaxBytes
}
if capacity.sessionMaxEntries <= 0 {
capacity.sessionMaxEntries = capacity.authMaxEntries
}
if capacity.subscriberMaxGlobal <= 0 {
capacity.subscriberMaxGlobal = rpcResultSubscriberMaxGlobal
}
if capacity.subscriberMaxAuth <= 0 {
capacity.subscriberMaxAuth = rpcResultSubscriberMaxAuth
}
if capacity.subscriberMaxSession <= 0 {
capacity.subscriberMaxSession = rpcResultSubscriberMaxSession
}
if capacity.subscriberMaxPerFlight <= 0 {
capacity.subscriberMaxPerFlight = rpcResultSubscriberMaxPerFlight
}
c := &rpcResultCache{hashSeed: maphash.MakeSeed(), sessions: capacity.sessions}
c.completedBytes.max = capacity.globalMaxBytes
c.completedEntries.max = int64(capacity.globalMaxEntries)
c.flightLimit.max = int64(capacity.maxPending)
c.fairBudget = newRPCResultFairBudget(
c.hashSeed,
&c.completedEntries,
&c.completedBytes,
rpcResultBudgetLimit{entries: int64(capacity.authMaxEntries), bytes: capacity.authMaxBytes},
rpcResultBudgetLimit{entries: int64(capacity.sessionMaxEntries), bytes: capacity.sessionMaxBytes},
capacity.maxPendingPerAuth,
)
c.subscriberBudget = newRPCResultSubscriberBudget(
c.hashSeed,
capacity.subscriberMaxGlobal,
capacity.subscriberMaxAuth,
capacity.subscriberMaxSession,
)
c.subscriberPerFlight = capacity.subscriberMaxPerFlight
for i := range c.shards {
s := &c.shards[i]
s.now = now
s.ttl = rpcResultCacheTTL
s.maxEntries = 0
s.order = list.New()
s.byKey = make(map[rpcResultCacheKey]*list.Element)
s.pending = make(map[rpcResultCacheKey]*rpcResultFlight)
}
return c
}
func (c *rpcResultCache) shard(key rpcResultCacheKey) *rpcResultCacheShard {
return &c.shards[c.shardIndex(key)]
}
func (c *rpcResultCache) shardIndex(key rpcResultCacheKey) uint64 {
var raw [24]byte
copy(raw[:8], key.authKeyID[:])
binary.LittleEndian.PutUint64(raw[8:16], uint64(key.sessionID))
binary.LittleEndian.PutUint64(raw[16:24], uint64(key.reqMsgID))
return maphash.Bytes(c.hashSeed, raw[:]) & (rpcResultCacheShards - 1)
}
func (c *rpcResultCache) Get(authKeyID [8]byte, sessionID, reqMsgID int64) (*encodedOutboundMessage, bool) {
if c == nil || reqMsgID == 0 {
return nil, false
}
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: reqMsgID}
s := c.shard(key)
now := s.now()
s.mu.Lock()
elem, ok := s.byKey[key]
if !ok {
s.mu.Unlock()
return nil, false
}
entry := elem.Value.(*rpcResultCacheEntry)
if !entry.expiresAt.After(now) {
s.removeElement(elem)
s.mu.Unlock()
return nil, false
}
if entry.capacity || entry.acknowledged {
s.mu.Unlock()
return nil, false
}
if c.sessions != nil {
s.mu.Unlock()
return c.sessions.rpcResult(authKeyID, sessionID, reqMsgID)
}
encoded := entry.encoded
s.mu.Unlock()
return encoded, encoded != nil
}
// Acknowledge removes a completed rpc_result receipt immediately.
// The caller must have resolved reqMsgID from a client msgs_ack through the
// sole outbound actor's tracked server-message mapping; an untrusted request
// msg_id must never call this method directly.
//
// ACK can race the asynchronous terminal Put. A pending flight records the
// event so Put resolves current waiters but does not retain a completed row.
// This follows the logical-session model: once the client has acknowledged the
// exact server message, neither its payload nor its duplicate receipt is useful.
func (c *rpcResultCache) Acknowledge(authKeyID [8]byte, sessionID, reqMsgID int64) bool {
if c == nil || reqMsgID == 0 {
return false
}
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: reqMsgID}
s := c.shard(key)
now := s.now()
s.mu.Lock()
s.expireLocked(now)
if elem := s.byKey[key]; elem != nil {
s.removeElement(elem)
s.mu.Unlock()
return true
}
if flight := s.pending[key]; flight != nil {
flight.acknowledged = true
s.mu.Unlock()
return true
}
s.mu.Unlock()
return false
}
// ObserveDependency returns a waiter for an admitted in-flight dependency, a
// nil waiter for an already completed dependency, or ok=false when the
// referenced message never established API-RPC ownership. It never creates a
// flight and therefore cannot turn a forged invokeAfterMsg into authority to
// run another request.
func (c *rpcResultCache) ObserveDependency(authKeyID [8]byte, sessionID, reqMsgID int64) (rpcResultDependency, bool) {
if c == nil || reqMsgID == 0 {
return rpcResultDependency{}, false
}
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: reqMsgID}
s := c.shard(key)
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
if elem, exists := s.byKey[key]; exists {
entry := elem.Value.(*rpcResultCacheEntry)
if entry.expiresAt.After(now) {
if !entry.executionKnown {
return rpcResultDependency{}, false
}
return rpcResultDependency{completed: true, success: entry.executionOK}, true
}
s.removeElement(elem)
}
if flight := s.pending[key]; flight != nil {
if flight.executionDone {
return rpcResultDependency{completed: true, success: flight.executionOK}, true
}
return rpcResultDependency{waiter: &rpcResultWaiter{cache: c, key: key, flight: flight}}, true
}
return rpcResultDependency{}, false
}
func (c *rpcResultCache) Put(authKeyID [8]byte, sessionID, reqMsgID int64, encoded *encodedOutboundMessage) {
if c == nil || reqMsgID == 0 || encoded == nil {
return
}
if c.putOnce(authKeyID, sessionID, reqMsgID, encoded) {
return
}
// A direct Put has no pre-reserved owner slot. Expired entries in another
// shard may be its only blocker; reap once without holding a shard and retry.
// Production owner publication already carries both reservations and never
// needs this cold path.
c.expireCompletedResults()
_ = c.putOnce(authKeyID, sessionID, reqMsgID, encoded)
}
// putOnce returns false only when a cross-shard expiry reap may release the
// process-wide entry/body capacity needed by a defensive direct Put.
func (c *rpcResultCache) putOnce(authKeyID [8]byte, sessionID, reqMsgID int64, encoded *encodedOutboundMessage) bool {
key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: reqMsgID}
s := c.shard(key)
logicalOutbox := c.sessions != nil
logicalReplayable := false
if logicalOutbox {
_, logicalReplayable = c.sessions.rpcResult(authKeyID, sessionID, reqMsgID)
}
accountedSize := len(encoded.body)
if logicalOutbox {
accountedSize = 1
}
if accountedSize < 1 {
// Every owner reserves one byte at admission. Keeping zero-length results
// at the same minimum makes entry and byte capacity linearizable.
accountedSize = 1
}
// Publication never evicts another unexpired result. A production owner has
// already reserved its entry slot and one byte. If its actual result cannot
// expand that reservation, publish a one-byte identity tombstone: the owner
// and current waiters still receive the immutable result, while later
// duplicates fail admission instead of rerunning the handler.
s.mu.Lock()
now := s.now()
s.expireLocked(now)
old := s.byKey[key]
flight := s.pending[key]
if old == nil && flight == nil && s.maxEntries > 0 && len(s.byKey) >= s.maxEntries {
// Defensive direct Put callers do not own a reserved admission slot.
// Preserve every existing unexpired result and decline the new cache row.
s.mu.Unlock()
return true
}
identity, admissionSeq, executionKnown, executionOK, acknowledged := rpcResultFlightMetadataLocked(s, key)
var oldEntry *rpcResultCacheEntry
if old != nil {
oldEntry = old.Value.(*rpcResultCacheEntry)
if flight == nil {
// The first terminal publication is immutable, whether it retained an
// legacy inline body, receipt or capacity tombstone. A
// stale callback must not replace exact bytes, extend TTL, resurrect an
// ACKed body or valid receipt.
s.mu.Unlock()
return true
}
}
var reservation *rpcResultBudgetReservation
switch {
case flight != nil:
reservation = flight.reservation
if reservation == nil {
s.mu.Unlock()
panic("mtprotoedge: pending rpc result has no fair-budget reservation")
}
case oldEntry != nil && oldEntry.reservation != nil:
reservation = oldEntry.reservation
default:
reservation = c.fairBudget.reserveCompleted(key, accountedSize)
if reservation == nil {
s.mu.Unlock()
return false
}
}
retainedSize := accountedSize
retained := encoded
capacity := false
if acknowledged {
const receiptSize = 1
if !reservation.resizeBytes(receiptSize) {
s.mu.Unlock()
panic("mtprotoedge: acknowledged rpc result owner lost its receipt reservation")
}
retainedSize = receiptSize
retained = nil
} else if logicalOutbox {
const receiptSize = 1
if !reservation.resizeBytes(receiptSize) {
s.mu.Unlock()
panic("mtprotoedge: logical rpc result owner lost its receipt reservation")
}
retainedSize = receiptSize
retained = nil
capacity = !logicalReplayable
} else if !reservation.resizeBytes(accountedSize) {
if flight == nil {
// A direct replacement cannot discard the prior replay body. Leave it
// untouched and let Put perform one cross-shard expiry reap before its
// final bounded failure.
if oldEntry == nil {
reservation.release()
}
s.mu.Unlock()
return false
}
// Owner admission already reserved one byte at all three scopes. When the
// actual body cannot expand, transfer that reservation to an identity
// tombstone so a duplicate never reruns business.
const tombstoneSize = 1
if !reservation.resizeBytes(tombstoneSize) {
s.mu.Unlock()
panic("mtprotoedge: rpc result owner lost its one-byte tombstone reservation")
}
retainedSize = tombstoneSize
retained = nil
capacity = true
}
if old != nil {
s.unlinkElement(old)
if oldEntry.reservation != nil && oldEntry.reservation != reservation {
oldEntry.reservation.release()
oldEntry.reservation = nil
}
}
entry := &rpcResultCacheEntry{
key: key,
encoded: retained,
size: retainedSize,
expiresAt: now.Add(s.ttl),
identity: identity,
admissionSeq: admissionSeq,
executionKnown: executionKnown,
executionOK: executionOK,
acknowledged: acknowledged,
capacity: capacity,
reservation: reservation,
}
elem := s.order.PushBack(entry)
s.byKey[key] = elem
s.bytes += retainedSize
// Resolve the independent in-flight entry only after either the completed
// result or its replay tombstone is published under the same shard lock.
subscribers, executionSubscribers, executionOK := c.completeRPCResultFlightLocked(s, key, encoded)
if acknowledged {
// ACK won before terminal publication. Current subscribers still receive
// encoded below, but no post-ACK receipt survives this critical section.
s.removeElement(elem)
}
s.mu.Unlock()
for _, subscriber := range subscribers {
subscriber(encoded, true)
}
for _, subscriber := range executionSubscribers {
subscriber(executionOK)
}
return true
}
func rpcResultFlightMetadataLocked(s *rpcResultCacheShard, key rpcResultCacheKey) (
rpcResultRequestIdentity,
uint64,
bool,
bool,
bool,
) {
if flight := s.pending[key]; flight != nil {
return flight.identity, flight.admissionSeq, flight.executionDone, flight.executionOK, flight.acknowledged
}
return rpcResultRequestIdentity{}, 0, false, false, false
}
// expireCompletedResults performs the cold-path cross-shard reap used only
// after a one-byte admission reservation fails. The caller must hold no shard
// lock. Each shard is reaped independently so ordinary result publication on
// the other shards remains parallel.
func (c *rpcResultCache) expireCompletedResults() {
if c == nil {
return
}
for i := range c.shards {
s := &c.shards[i]
s.mu.Lock()
s.expireLocked(s.now())
s.mu.Unlock()
}
}
func (c *rpcResultCache) Close() error {
return nil
}
func (c *rpcResultCache) CloseContext(context.Context) error {
return nil
}
// forgetCompletedSession removes every terminal receipt for a logical session.
// SessionManager invokes it only after the session's physical producers have
// converged, so no pending owner should remain and no later terminal callback
// can recreate a receipt for the destroyed outbox.
func (c *rpcResultCache) forgetCompletedSession(authKeyID [8]byte, sessionID int64) {
if c == nil {
return
}
for i := range c.shards {
s := &c.shards[i]
s.mu.Lock()
for elem := s.order.Front(); elem != nil; {
next := elem.Next()
entry := elem.Value.(*rpcResultCacheEntry)
if entry.key.authKeyID == authKeyID && entry.key.sessionID == sessionID {
s.removeElement(elem)
}
elem = next
}
s.mu.Unlock()
}
}
func (s *rpcResultCacheShard) expireLocked(now time.Time) {
for elem := s.order.Front(); elem != nil; {
next := elem.Next()
entry := elem.Value.(*rpcResultCacheEntry)
if entry.expiresAt.After(now) {
return
}
s.removeElement(elem)
elem = next
}
}
func (s *rpcResultCacheShard) removeElement(elem *list.Element) {
entry := s.unlinkElement(elem)
if entry != nil && entry.reservation != nil {
entry.reservation.release()
entry.reservation = nil
}
}
func (s *rpcResultCacheShard) unlinkElement(elem *list.Element) *rpcResultCacheEntry {
if elem == nil {
return nil
}
entry := elem.Value.(*rpcResultCacheEntry)
delete(s.byKey, entry.key)
s.bytes -= entry.size
if s.bytes < 0 {
s.bytes = 0
}
s.order.Remove(elem)
return entry
}
type rpcResultCacheByteBudget struct {
max int64
used atomic.Int64
}
func (b *rpcResultCacheByteBudget) reserve(n int) bool {
if n <= 0 {
return true
}
bytes := int64(n)
if b == nil || bytes > b.max {
return false
}
for {
used := b.used.Load()
if used > b.max-bytes {
return false
}
if b.used.CompareAndSwap(used, used+bytes) {
return true
}
}
}
func (b *rpcResultCacheByteBudget) release(n int) {
if b == nil || n <= 0 {
return
}
if remaining := b.used.Add(-int64(n)); remaining < 0 {
panic("mtprotoedge: rpc result completed-byte budget underflow")
}
}
func (b *rpcResultCacheByteBudget) snapshot() int64 {
if b == nil {
return 0
}
return b.used.Load()
}