Follow up PR #22 by projecting its composite rating through userFull while keeping profile reads cache-backed and strictly read-only. Source-Commit: 00eea44c Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
454 lines
15 KiB
Go
454 lines
15 KiB
Go
// Package rating implements the composite account rating use cases: reading the
|
|
// stored projection, recomputing it from the raw contribution signals, and
|
|
// applying operator adjustments through the contribution ledger.
|
|
//
|
|
// This is gramsrv's local rating model, not a 1:1 reproduction of Telegram's
|
|
// private algorithm. The service gathers signals, applies the configured
|
|
// weights and pending-delay policy, and persists the result under optimistic
|
|
// concurrency for both admin and read-only client projection.
|
|
package rating
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"telesrv/internal/domain"
|
|
"telesrv/internal/store"
|
|
)
|
|
|
|
const (
|
|
// defaultPendingDelay parks a rating increase for a day, matching the
|
|
// shipped TELESRV_RATING_PENDING_DELAY default.
|
|
defaultPendingDelay = 24 * time.Hour
|
|
// defaultStaleAfter is the recompute horizon used when none is configured.
|
|
defaultStaleAfter = 6 * time.Hour
|
|
// defaultListLimit / maxListLimit bound one leaderboard page.
|
|
defaultListLimit = 50
|
|
maxListLimit = 200
|
|
// defaultEventLimit / maxEventLimit bound one ledger page.
|
|
defaultEventLimit = 50
|
|
maxEventLimit = 200
|
|
// defaultRecomputeBatch / maxRecomputeBatch bound one worker cycle.
|
|
defaultRecomputeBatch = 500
|
|
maxRecomputeBatch = 10000
|
|
)
|
|
|
|
// ErrDisabled reports that the local composite rating feature is switched off.
|
|
// Reads degrade to an empty admin projection; writes are refused so an operator
|
|
// never believes an adjustment was recorded when it was not.
|
|
var ErrDisabled = errors.New("account rating is disabled")
|
|
|
|
// Service is the composite account rating use-case layer.
|
|
type Service struct {
|
|
store store.AccountRatingStore
|
|
weights domain.AccountRatingWeights
|
|
pendingDelay time.Duration
|
|
staleAfter time.Duration
|
|
enabled bool
|
|
|
|
now func() time.Time
|
|
log *zap.Logger
|
|
}
|
|
|
|
// Option adjusts optional service dependencies.
|
|
type Option func(*Service)
|
|
|
|
// WithStore injects the rating read model and ledger store.
|
|
func WithStore(st store.AccountRatingStore) Option {
|
|
return func(s *Service) { s.store = st }
|
|
}
|
|
|
|
// WithWeights installs the composite formula. An invalid set is rejected in
|
|
// favour of the shipped defaults, so a misconfigured deployment produces a
|
|
// conservative rating instead of an inconsistent one.
|
|
func WithWeights(weights domain.AccountRatingWeights) Option {
|
|
return func(s *Service) {
|
|
if err := weights.Validate(); err != nil {
|
|
return
|
|
}
|
|
s.weights = weights
|
|
}
|
|
}
|
|
|
|
// WithPendingDelay configures how long a rating increase stays parked as
|
|
// pending. Zero applies every change immediately.
|
|
func WithPendingDelay(delay time.Duration) Option {
|
|
return func(s *Service) {
|
|
if delay >= 0 {
|
|
s.pendingDelay = delay
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithStaleAfter configures the projection age after which the background
|
|
// worker recomputes a user.
|
|
func WithStaleAfter(staleAfter time.Duration) Option {
|
|
return func(s *Service) {
|
|
if staleAfter > 0 {
|
|
s.staleAfter = staleAfter
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithEnabled toggles the feature.
|
|
func WithEnabled(enabled bool) Option {
|
|
return func(s *Service) { s.enabled = enabled }
|
|
}
|
|
|
|
// WithClock injects the clock (tests).
|
|
func WithClock(now func() time.Time) Option {
|
|
return func(s *Service) {
|
|
if now != nil {
|
|
s.now = now
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithLogger injects the service logger.
|
|
func WithLogger(log *zap.Logger) Option {
|
|
return func(s *Service) {
|
|
if log != nil {
|
|
s.log = log
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewService creates the rating service. It is enabled by default so that the
|
|
// only switch is the configuration flag, and it stays safe without a store:
|
|
// reads answer empty and writes report a configuration error.
|
|
func NewService(opts ...Option) *Service {
|
|
s := &Service{
|
|
weights: domain.DefaultAccountRatingWeights(),
|
|
pendingDelay: defaultPendingDelay,
|
|
staleAfter: defaultStaleAfter,
|
|
enabled: true,
|
|
now: time.Now,
|
|
log: zap.NewNop(),
|
|
}
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
opt(s)
|
|
}
|
|
}
|
|
if s.now == nil {
|
|
s.now = time.Now
|
|
}
|
|
if s.log == nil {
|
|
s.log = zap.NewNop()
|
|
}
|
|
if s.pendingDelay < 0 {
|
|
s.pendingDelay = 0
|
|
}
|
|
if s.staleAfter <= 0 {
|
|
s.staleAfter = defaultStaleAfter
|
|
}
|
|
if err := s.weights.Validate(); err != nil {
|
|
s.weights = domain.DefaultAccountRatingWeights()
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Enabled reports whether the feature is switched on.
|
|
func (s *Service) Enabled() bool { return s != nil && s.enabled }
|
|
|
|
// Ready reports whether the feature is on and backed by a store.
|
|
func (s *Service) Ready() bool { return s.Enabled() && s.store != nil }
|
|
|
|
// Weights returns the configured composite formula, so the admin panel can
|
|
// explain a level with the same numbers that produced it.
|
|
func (s *Service) Weights() domain.AccountRatingWeights {
|
|
if s == nil {
|
|
return domain.DefaultAccountRatingWeights()
|
|
}
|
|
return s.weights
|
|
}
|
|
|
|
func (s *Service) ratingStore() (store.AccountRatingStore, error) {
|
|
if s == nil || s.store == nil {
|
|
return nil, fmt.Errorf("account rating store is not configured")
|
|
}
|
|
return s.store, nil
|
|
}
|
|
|
|
// Rating returns the stored projection.
|
|
//
|
|
// domain.ErrAccountRatingNotFound is propagated rather than flattened to a zero
|
|
// value so the admin API can distinguish "not computed" from a computed zero.
|
|
// A missing store reports a configuration error an operator can diagnose.
|
|
func (s *Service) Rating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
|
if s == nil || !s.enabled || userID <= 0 {
|
|
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
|
}
|
|
st, err := s.ratingStore()
|
|
if err != nil {
|
|
return domain.AccountRating{}, err
|
|
}
|
|
return st.AccountRating(ctx, userID)
|
|
}
|
|
|
|
// RatingBatch resolves several users in one round trip. Users without a stored
|
|
// projection are absent from the map, so a disabled feature and an unconfigured
|
|
// store both read as "nobody has a rating" -- the batch shape already encodes
|
|
// absence and needs no error to express it.
|
|
func (s *Service) RatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
|
if s == nil || !s.enabled || s.store == nil {
|
|
return map[int64]domain.AccountRating{}, nil
|
|
}
|
|
unique := make([]int64, 0, len(userIDs))
|
|
seen := make(map[int64]struct{}, len(userIDs))
|
|
for _, userID := range userIDs {
|
|
if userID <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[userID]; ok {
|
|
continue
|
|
}
|
|
seen[userID] = struct{}{}
|
|
unique = append(unique, userID)
|
|
}
|
|
if len(unique) == 0 {
|
|
return map[int64]domain.AccountRating{}, nil
|
|
}
|
|
batch, err := s.store.AccountRatingBatch(ctx, unique)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if batch == nil {
|
|
return map[int64]domain.AccountRating{}, nil
|
|
}
|
|
return batch, nil
|
|
}
|
|
|
|
// Recompute gathers the contribution signals, applies the configured weights
|
|
// and the pending-delay policy relative to the stored value, and persists the
|
|
// result.
|
|
//
|
|
// The save is guarded by the stored version. A concurrent writer (another
|
|
// recompute, an adjustment, the worker) only invalidates the base the pending
|
|
// policy was resolved against, so exactly one retry against the freshly
|
|
// returned row is both sufficient and terminating.
|
|
func (s *Service) Recompute(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
|
if s == nil || !s.enabled {
|
|
return domain.AccountRating{}, ErrDisabled
|
|
}
|
|
st, err := s.ratingStore()
|
|
if err != nil {
|
|
return domain.AccountRating{}, err
|
|
}
|
|
if userID <= 0 {
|
|
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
|
|
}
|
|
// The service accounts are infrastructure, not participants. Refusing here as
|
|
// well as in the seeding query means an operator cannot create a rating for one
|
|
// by hand either -- the platform account is not flagged is_bot, so nothing else
|
|
// would stop it.
|
|
if !domain.RatableAccount(userID, false) {
|
|
return domain.AccountRating{}, domain.ErrAccountRatingAdjustmentInvalid
|
|
}
|
|
signals, err := st.AccountRatingSignals(ctx, userID)
|
|
if err != nil {
|
|
return domain.AccountRating{}, err
|
|
}
|
|
signals.UserID = userID
|
|
prev, err := s.previous(ctx, st, userID)
|
|
if err != nil {
|
|
return domain.AccountRating{}, err
|
|
}
|
|
now := s.now().UTC()
|
|
computed := domain.ComputeAccountRating(signals, s.weights, now)
|
|
stored, changed, err := st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(prev, computed, s.pendingDelay, now))
|
|
if err != nil {
|
|
return domain.AccountRating{}, err
|
|
}
|
|
if changed {
|
|
return stored, nil
|
|
}
|
|
// One retry: `stored` is the row that won the race, so resolving the pending
|
|
// policy against it produces the correct next version.
|
|
stored, changed, err = st.SaveAccountRating(ctx, domain.ResolveAccountRatingPending(stored, computed, s.pendingDelay, now))
|
|
if err != nil {
|
|
return domain.AccountRating{}, err
|
|
}
|
|
if !changed {
|
|
return stored, fmt.Errorf("recompute account rating %d: concurrent version conflict", userID)
|
|
}
|
|
return stored, nil
|
|
}
|
|
|
|
// Adjust records an operator adjustment in the contribution ledger and
|
|
// immediately recomputes the projection, so the manual component is visible
|
|
// without waiting for the background worker. Replaying the same CommandKey
|
|
// records nothing and reports applied=false; the current rating is still
|
|
// returned so a retried admin command stays idempotent.
|
|
func (s *Service) Adjust(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRating, bool, error) {
|
|
if s == nil || !s.enabled {
|
|
return domain.AccountRating{}, false, ErrDisabled
|
|
}
|
|
st, err := s.ratingStore()
|
|
if err != nil {
|
|
return domain.AccountRating{}, false, err
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
return domain.AccountRating{}, false, err
|
|
}
|
|
_, applied, err := st.AdjustAccountRating(ctx, req)
|
|
if err != nil {
|
|
return domain.AccountRating{}, false, err
|
|
}
|
|
rating, err := s.Recompute(ctx, req.UserID)
|
|
if err != nil {
|
|
return domain.AccountRating{}, applied, err
|
|
}
|
|
return rating, applied, nil
|
|
}
|
|
|
|
// List is the admin leaderboard query with a bounded page size.
|
|
func (s *Service) List(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
|
if s == nil || !s.enabled {
|
|
return nil, nil
|
|
}
|
|
st, err := s.ratingStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if filter.MinLevel < 0 {
|
|
filter.MinLevel = 0
|
|
}
|
|
if filter.MinLevel > domain.MaxAccountRatingLevel {
|
|
filter.MinLevel = domain.MaxAccountRatingLevel
|
|
}
|
|
filter.Limit = clampLimit(filter.Limit, defaultListLimit, maxListLimit)
|
|
return st.ListAccountRatings(ctx, filter)
|
|
}
|
|
|
|
// Events returns one user's contribution ledger, newest first.
|
|
func (s *Service) Events(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
|
if s == nil || !s.enabled {
|
|
return nil, nil
|
|
}
|
|
st, err := s.ratingStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if userID <= 0 {
|
|
return nil, domain.ErrAccountRatingAdjustmentInvalid
|
|
}
|
|
return st.AccountRatingEvents(ctx, userID, clampLimit(limit, defaultEventLimit, maxEventLimit))
|
|
}
|
|
|
|
// RunRecomputeCycle advances the read model by one bounded batch and returns how
|
|
// many users it wrote. A single user's failure is logged and skipped: one poisoned
|
|
// row must not stall the whole cycle.
|
|
//
|
|
// The cycle does two things, and the order matters. It first refreshes projections
|
|
// that have gone stale, because those are rows somebody is already looking at.
|
|
// Whatever batch budget is left it spends seeding accounts that have no projection
|
|
// at all -- without that pass the read model can never populate itself, since
|
|
// StaleAccountRatings walks account_rating and cannot return a user who is not in
|
|
// it. Staleness keeps existing ratings honest; seeding is what makes them exist at
|
|
// all, which is what makes the admin leaderboard populate without an operator
|
|
// opening every account first.
|
|
func (s *Service) RunRecomputeCycle(ctx context.Context, limit int) (int, error) {
|
|
if s == nil || !s.enabled {
|
|
return 0, nil
|
|
}
|
|
st, err := s.ratingStore()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
limit = clampLimit(limit, defaultRecomputeBatch, maxRecomputeBatch)
|
|
olderThan := s.now().UTC().Add(-s.staleAfter).Unix()
|
|
userIDs, err := st.StaleAccountRatings(ctx, olderThan, limit)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
processed, err := s.recomputeEach(ctx, userIDs, "recompute account rating failed")
|
|
if err != nil {
|
|
return processed, err
|
|
}
|
|
// The bound belongs to the cycle, not to each pass, so a backlog of stale rows
|
|
// can never turn one cycle into an unbounded amount of work.
|
|
remaining := limit - len(userIDs)
|
|
if remaining <= 0 {
|
|
return processed, nil
|
|
}
|
|
unrated, err := st.UnratedAccounts(ctx, remaining)
|
|
if err != nil {
|
|
// Seeding extends the cycle rather than being its purpose: a store that
|
|
// cannot enumerate accounts must not turn a successful stale pass into a
|
|
// failed cycle.
|
|
s.log.Warn("list unrated accounts failed", zap.Error(err))
|
|
return processed, nil
|
|
}
|
|
seeded, err := s.recomputeEach(ctx, unrated, "seed account rating failed")
|
|
return processed + seeded, err
|
|
}
|
|
|
|
// recomputeEach recomputes a list of users, skipping the ones that fail, and
|
|
// giving up early only when the context is done.
|
|
func (s *Service) recomputeEach(ctx context.Context, userIDs []int64, failureMessage string) (int, error) {
|
|
processed := 0
|
|
for _, userID := range userIDs {
|
|
if err := ctx.Err(); err != nil {
|
|
return processed, err
|
|
}
|
|
if userID <= 0 {
|
|
continue
|
|
}
|
|
if _, err := s.Recompute(ctx, userID); err != nil {
|
|
s.log.Warn(failureMessage,
|
|
zap.Int64("user_id", userID),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
processed++
|
|
}
|
|
return processed, nil
|
|
}
|
|
|
|
// EnsureRating returns the stored local-admin projection, computing and storing
|
|
// it first when an administrative caller needs an immediate value.
|
|
//
|
|
// The background cycle reaches every account eventually; callers that require a
|
|
// local rating immediately use this bounded materialization path instead.
|
|
func (s *Service) EnsureRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
|
if s == nil || !s.enabled || userID <= 0 {
|
|
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
|
}
|
|
rating, err := s.Rating(ctx, userID)
|
|
if err == nil {
|
|
return rating, nil
|
|
}
|
|
if !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
|
return domain.AccountRating{}, err
|
|
}
|
|
return s.Recompute(ctx, userID)
|
|
}
|
|
|
|
// previous reads the stored projection the pending policy is resolved against.
|
|
// A never-computed user yields the zero value, which domain.ResolveAccountRating
|
|
// Pending treats as "apply immediately" -- a first rating is never parked.
|
|
func (s *Service) previous(ctx context.Context, st store.AccountRatingStore, userID int64) (domain.AccountRating, error) {
|
|
prev, err := st.AccountRating(ctx, userID)
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrAccountRatingNotFound) {
|
|
return domain.AccountRating{}, nil
|
|
}
|
|
return domain.AccountRating{}, err
|
|
}
|
|
return prev, nil
|
|
}
|
|
|
|
func clampLimit(limit, fallback, maximum int) int {
|
|
if limit <= 0 {
|
|
return fallback
|
|
}
|
|
if limit > maximum {
|
|
return maximum
|
|
}
|
|
return limit
|
|
}
|