package store import ( "context" "errors" "fmt" "sync" "time" "telesrv/internal/domain" ) // ReadModelVersionBatchConfig bounds synchronous cross-request batching of // durable read-model hash misses. MaxKeys limits the union sent to one database // query; QueueSize limits accepted requests, not individual keys. type ReadModelVersionBatchConfig struct { MaxKeys int MaxWait time.Duration QueueSize int QueryTimeout time.Duration } type readModelVersionBatchRequest struct { ctx context.Context keys []ReadModelKey result chan readModelVersionBatchResult } type readModelVersionBatchResult struct { hashes map[ReadModelKey]int64 err error } // BatchedReadModelVersionStore combines contemporaneous exact-key misses into // one base ReadModelHashes call. It is deliberately below // CachedReadModelVersionStore: the cache still owns exact-key inflight // singleflight, NOTIFY updates and reconnect flushes, while this layer only // reduces cold-burst database acquisitions. There is no direct-query fallback // when the bounded queue or shared query fails. type BatchedReadModelVersionStore struct { base ReadModelVersionStore cfg ReadModelVersionBatchConfig queue chan readModelVersionBatchRequest stop chan struct{} done chan struct{} cancel context.CancelFunc once sync.Once gate sync.RWMutex closed bool } func NewBatchedReadModelVersionStore( base ReadModelVersionStore, cfg ReadModelVersionBatchConfig, ) (*BatchedReadModelVersionStore, error) { if base == nil { return nil, errors.New("initialize read-model version batcher: nil store") } if cfg.MaxKeys <= 0 || cfg.MaxKeys > 1<<16 { return nil, fmt.Errorf("initialize read-model version batcher: max keys %d outside [1,65536]", cfg.MaxKeys) } if cfg.MaxWait <= 0 || cfg.MaxWait > 10*time.Millisecond { return nil, fmt.Errorf("initialize read-model version batcher: max wait %v outside (0,10ms]", cfg.MaxWait) } if cfg.QueueSize <= 0 || cfg.QueueSize > 1<<20 { return nil, fmt.Errorf("initialize read-model version batcher: queue size %d outside [1,1048576]", cfg.QueueSize) } if cfg.QueryTimeout <= 0 || cfg.QueryTimeout > 30*time.Second { return nil, fmt.Errorf("initialize read-model version batcher: query timeout %v outside (0,30s]", cfg.QueryTimeout) } workerCtx, cancel := context.WithCancel(context.Background()) s := &BatchedReadModelVersionStore{ base: base, cfg: cfg, queue: make(chan readModelVersionBatchRequest, cfg.QueueSize), stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel, } go s.run(workerCtx) return s, nil } func (s *BatchedReadModelVersionStore) ReadModelHash( ctx context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64, ) (int64, bool, error) { if model == "" { return 0, false, nil } key := ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID} rows, err := s.ReadModelHashes(ctx, []ReadModelKey{key}) if err != nil { return 0, false, err } hash := rows[key] return hash, hash != 0, nil } func (s *BatchedReadModelVersionStore) ReadModelHashes( ctx context.Context, keys []ReadModelKey, ) (map[ReadModelKey]int64, error) { out := make(map[ReadModelKey]int64, len(keys)) if s == nil || s.base == nil || len(keys) == 0 { return out, nil } if ctx == nil { ctx = context.Background() } unique := make([]ReadModelKey, 0, len(keys)) seen := make(map[ReadModelKey]struct{}, len(keys)) for _, key := range keys { if key.Model == "" { continue } if _, duplicate := seen[key]; duplicate { continue } seen[key] = struct{}{} unique = append(unique, key) } for start := 0; start < len(unique); start += s.cfg.MaxKeys { end := start + s.cfg.MaxKeys if end > len(unique) { end = len(unique) } rows, err := s.readChunk(ctx, unique[start:end]) if err != nil { return nil, err } for key, hash := range rows { out[key] = hash } } return out, nil } func (s *BatchedReadModelVersionStore) readChunk( ctx context.Context, keys []ReadModelKey, ) (map[ReadModelKey]int64, error) { request := readModelVersionBatchRequest{ ctx: ctx, keys: append([]ReadModelKey(nil), keys...), result: make(chan readModelVersionBatchResult, 1), } s.gate.RLock() if s.closed { s.gate.RUnlock() return nil, context.Canceled } select { case s.queue <- request: case <-ctx.Done(): s.gate.RUnlock() return nil, ctx.Err() } s.gate.RUnlock() select { case result := <-request.result: return result.hashes, result.err case <-ctx.Done(): return nil, ctx.Err() } } func (s *BatchedReadModelVersionStore) Close() { if s == nil { return } s.once.Do(func() { s.gate.Lock() s.closed = true close(s.stop) s.cancel() s.gate.Unlock() <-s.done }) } func (s *BatchedReadModelVersionStore) run(ctx context.Context) { defer close(s.done) var carry *readModelVersionBatchRequest for { batch := make([]readModelVersionBatchRequest, 0, 32) keyCount := 0 if carry != nil { batch = append(batch, *carry) keyCount = len(carry.keys) carry = nil } else { select { case request := <-s.queue: batch = append(batch, request) keyCount = len(request.keys) case <-s.stop: s.failQueued(context.Canceled, nil) return } } timer := time.NewTimer(s.cfg.MaxWait) collect: for keyCount < s.cfg.MaxKeys { select { case request := <-s.queue: if keyCount+len(request.keys) > s.cfg.MaxKeys { carry = &request break collect } batch = append(batch, request) keyCount += len(request.keys) case <-timer.C: break collect case <-s.stop: if !timer.Stop() { select { case <-timer.C: default: } } if carry != nil { batch = append(batch, *carry) carry = nil } s.failQueued(context.Canceled, batch) return } } if !timer.Stop() { select { case <-timer.C: default: } } s.execute(ctx, batch) } } func (s *BatchedReadModelVersionStore) execute(ctx context.Context, batch []readModelVersionBatchRequest) { active := batch[:0] union := make([]ReadModelKey, 0) seen := make(map[ReadModelKey]struct{}) for _, request := range batch { if err := request.ctx.Err(); err != nil { request.result <- readModelVersionBatchResult{err: err} continue } active = append(active, request) for _, key := range request.keys { if _, duplicate := seen[key]; duplicate { continue } seen[key] = struct{}{} union = append(union, key) } } if len(active) == 0 { return } queryCtx, cancel := context.WithTimeout(ctx, s.cfg.QueryTimeout) loaded, err := s.base.ReadModelHashes(queryCtx, union) cancel() if err != nil { for _, request := range active { request.result <- readModelVersionBatchResult{err: err} } return } for _, request := range active { rows := make(map[ReadModelKey]int64, len(request.keys)) for _, key := range request.keys { if hash, found := loaded[key]; found { rows[key] = hash } } request.result <- readModelVersionBatchResult{hashes: rows} } } func (s *BatchedReadModelVersionStore) failQueued(err error, pending []readModelVersionBatchRequest) { for _, request := range pending { request.result <- readModelVersionBatchResult{err: err} } for { select { case request := <-s.queue: request.result <- readModelVersionBatchResult{err: err} default: return } } } var _ ReadModelVersionStore = (*BatchedReadModelVersionStore)(nil)