Files
tm_back/internal/tender/recommendation_page_cache.go
T
Mazyar 784c3d6563
continuous-integration/drone/push Build is passing
Refactor recommendation page cache handling and configuration
- Removed the `RecommendationPageCacheLanguages` configuration from `AISummarizerConfig` to streamline cache management.
- Updated the `companyService` and `tenderService` to utilize the new `InvalidateRecommendedTendersPageCache` method for cache invalidation, enhancing clarity and efficiency.
- Refactored the `invalidateRecommendedTendersPageCache` method to eliminate unnecessary context parameters, simplifying the function signature.
- Improved the handling of page cache refresh logic by consolidating language handling within the `tenderService`, ensuring consistent behavior across services.
- Cleaned up related tests and removed deprecated functions to maintain code quality and readability.

This update enhances the maintainability of the recommendation caching system by simplifying configuration and improving cache invalidation logic.
2026-07-08 02:20:16 +03:30

401 lines
11 KiB
Go

package tender
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/redis/go-redis/v9"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/sync/errgroup"
"tm/internal/company"
"tm/pkg/response"
)
const (
recommendedTendersPageCacheKeyPrefix = "ai:recommendations:page:"
recommendationPageCacheReadTimeout = 3 * time.Second
recommendationPageCacheBuildTimeout = 5 * time.Minute
recommendationPageCacheConcurrency = 8
)
type cachedRecommendedTendersPage struct {
BuiltAt int64 `json:"built_at"`
CompanyID string `json:"company_id"`
Language string `json:"language"`
Items []TenderResponse `json:"items"`
}
func recommendedTendersPageCacheKey(companyID, language string) string {
return recommendedTendersPageCacheKeyPrefix +
strings.TrimSpace(companyID) + ":" +
strings.ToLower(strings.TrimSpace(language))
}
// ScheduleRefreshRecommendedTendersPageCache rebuilds the page cache for the service default language.
func (s *tenderService) ScheduleRefreshRecommendedTendersPageCache(companyID string) {
s.schedulePageCacheRefreshForLanguage(companyID, s.defaultLanguage)
}
// InvalidateRecommendedTendersPageCache drops the cached page for the service default language.
func (s *tenderService) InvalidateRecommendedTendersPageCache(companyID string) {
companyID = strings.TrimSpace(companyID)
if companyID == "" || s.redisClient == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), recommendationPageCacheReadTimeout)
defer cancel()
s.deleteRecommendedTendersPageCache(ctx, companyID, s.defaultLanguage)
}
func (s *tenderService) schedulePageCacheRefreshForLanguage(companyID, language string) {
companyID = strings.TrimSpace(companyID)
language = strings.ToLower(strings.TrimSpace(language))
if companyID == "" || language == "" || s.redisClient == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), recommendationPageCacheBuildTimeout)
defer cancel()
if err := s.buildRecommendedTendersPageCache(ctx, companyID, language); err != nil {
s.logger.Warn("Failed to refresh recommended tenders page cache", map[string]interface{}{
"company_id": companyID,
"language": language,
"error": err.Error(),
})
}
}()
}
func (s *tenderService) buildRecommendedTendersPageCache(ctx context.Context, companyID, language string) error {
recommendations, err := s.companyService.GetAIRecommendations(ctx, companyID)
if err != nil {
return err
}
if len(recommendations) == 0 {
s.deleteRecommendedTendersPageCache(ctx, companyID, language)
return nil
}
items, err := s.buildAllRankedRecommendations(ctx, recommendations)
if err != nil {
return err
}
if len(items) == 0 {
s.deleteRecommendedTendersPageCache(ctx, companyID, language)
return nil
}
responses := s.buildRecommendedTenderResponsesParallel(ctx, items, language)
page := cachedRecommendedTendersPage{
BuiltAt: time.Now().Unix(),
CompanyID: companyID,
Language: language,
Items: responses,
}
encoded, err := json.Marshal(page)
if err != nil {
return fmt.Errorf("encode recommended tenders page cache: %w", err)
}
writeCtx, cancel := context.WithTimeout(ctx, recommendationPageCacheReadTimeout)
defer cancel()
if err := s.redisClient.Set(writeCtx, recommendedTendersPageCacheKey(companyID, language), string(encoded), s.pageCacheTTL); err != nil {
return fmt.Errorf("store recommended tenders page cache: %w", err)
}
s.logger.Info("Recommended tenders page cache built", map[string]interface{}{
"company_id": companyID,
"language": language,
"count": len(responses),
})
return nil
}
func (s *tenderService) getRecommendedTendersPageCache(ctx context.Context, companyID, language string) ([]TenderResponse, bool) {
if s.redisClient == nil {
return nil, false
}
readCtx, cancel := context.WithTimeout(ctx, recommendationPageCacheReadTimeout)
defer cancel()
raw, err := s.redisClient.Get(readCtx, recommendedTendersPageCacheKey(companyID, language))
if err != nil {
if err != redis.Nil {
s.logger.Warn("Failed to read recommended tenders page cache", map[string]interface{}{
"company_id": companyID,
"language": language,
"error": err.Error(),
})
}
return nil, false
}
var page cachedRecommendedTendersPage
if err := json.Unmarshal([]byte(raw), &page); err != nil {
s.logger.Warn("Failed to decode recommended tenders page cache", map[string]interface{}{
"company_id": companyID,
"language": language,
"error": err.Error(),
})
s.deleteRecommendedTendersPageCache(ctx, companyID, language)
return nil, false
}
if len(page.Items) == 0 {
return nil, false
}
return page.Items, true
}
func (s *tenderService) deleteRecommendedTendersPageCache(ctx context.Context, companyID, language string) {
if s.redisClient == nil {
return
}
_ = s.redisClient.Del(ctx, recommendedTendersPageCacheKey(companyID, language))
}
func (s *tenderService) buildAllRankedRecommendations(
ctx context.Context,
recommendations []company.RecommendedTenderResponse,
) ([]rankedRecommendedTender, error) {
seenTenderIDs := make(map[string]struct{}, len(recommendations))
out := make([]rankedRecommendedTender, 0, len(recommendations))
for start := 0; start < len(recommendations); start += recommendationResolveBatchSize {
end := start + recommendationResolveBatchSize
if end > len(recommendations) {
end = len(recommendations)
}
tenderByRef, err := s.resolveRecommendedTenders(ctx, recommendations[start:end])
if err != nil {
return nil, err
}
for _, rec := range recommendations[start:end] {
tenderRef := strings.TrimSpace(rec.TenderID)
tender, ok := tenderByRef[tenderRef]
if !ok {
continue
}
if markResolvedRecommendedTenderSeen(seenTenderIDs, tender, tenderRef) {
continue
}
out = append(out, rankedRecommendedTender{
tender: tender,
rank: rec.Rank,
analysis: rec.Analysis,
tenderRef: tenderRef,
})
}
}
return out, nil
}
func (s *tenderService) buildRecommendedTenderResponsesParallel(
ctx context.Context,
items []rankedRecommendedTender,
language string,
) []TenderResponse {
if len(items) == 0 {
return nil
}
out := make([]TenderResponse, len(items))
if len(items) == 1 {
out[0] = s.buildRecommendedTenderResponse(
ctx, items[0].tender, items[0].rank, items[0].analysis, items[0].tenderRef, language,
newRecommendationTranslationCache(),
)
return out
}
translationCache := newRecommendationTranslationCache()
group, groupCtx := errgroup.WithContext(ctx)
group.SetLimit(recommendationPageCacheConcurrency)
for i := range items {
i := i
item := items[i]
group.Go(func() error {
out[i] = s.buildRecommendedTenderResponse(
groupCtx, item.tender, item.rank, item.analysis, item.tenderRef, language, translationCache,
)
return nil
})
}
if err := group.Wait(); err != nil {
s.logger.Warn("Parallel recommendation page cache build degraded to sequential", map[string]interface{}{
"error": err.Error(),
"count": len(items),
})
for i, item := range items {
out[i] = s.buildRecommendedTenderResponse(
ctx, item.tender, item.rank, item.analysis, item.tenderRef, language, translationCache,
)
}
}
return out
}
func (s *tenderService) recommendFromPageCache(
ctx context.Context,
companyID string,
companyIDs []string,
form *SearchForm,
pagination *response.Pagination,
lang string,
) (*SearchResponse, bool) {
var cached []TenderResponse
if len(companyIDs) > 0 {
cached = s.mergeCachedRecommendationPages(ctx, companyIDs, lang)
} else {
items, ok := s.getRecommendedTendersPageCache(ctx, companyID, lang)
if !ok {
return nil, false
}
cached = items
}
if len(cached) == 0 {
return nil, false
}
excluded, excludedByCompany, err := s.loadRecommendationExclusions(ctx, form, companyID, companyIDs)
if err != nil {
return nil, false
}
now := time.Now().Unix()
filtered := filterCachedRecommendedTenders(cached, form, companyIDs, excluded, excludedByCompany, now)
total := int64(len(filtered))
start := pagination.Offset
if start > len(filtered) {
start = len(filtered)
}
end := start + pagination.Limit
if end > len(filtered) {
end = len(filtered)
}
return &SearchResponse{
Tenders: append([]TenderResponse(nil), filtered[start:end]...),
Metadata: pagination.ListMeta(total, "", end < len(filtered), start),
}, true
}
func (s *tenderService) mergeCachedRecommendationPages(ctx context.Context, companyIDs []string, lang string) []TenderResponse {
normalized := normalizeRecommendationCompanyScope(companyIDs)
if len(normalized) == 0 {
return nil
}
lists := make([][]TenderResponse, 0, len(normalized))
for _, companyID := range normalized {
items, ok := s.getRecommendedTendersPageCache(ctx, companyID, lang)
if !ok || len(items) == 0 {
return nil
}
lists = append(lists, items)
}
merged := make([]TenderResponse, 0)
for _, list := range lists {
merged = append(merged, list...)
}
return dedupeCachedRecommendedTendersByID(merged)
}
func dedupeCachedRecommendedTendersByID(items []TenderResponse) []TenderResponse {
seen := make(map[string]struct{}, len(items))
out := make([]TenderResponse, 0, len(items))
for _, item := range items {
key := strings.TrimSpace(item.ID)
if key == "" {
key = strings.TrimSpace(item.ProcedureRef)
}
if key == "" {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, item)
}
return out
}
func filterCachedRecommendedTenders(
items []TenderResponse,
form *SearchForm,
companyIDs []string,
excluded map[string]struct{},
excludedByCompany map[string]map[string]struct{},
now int64,
) []TenderResponse {
if len(items) == 0 {
return nil
}
out := make([]TenderResponse, 0, len(items))
for _, item := range items {
tender := tenderFromRecommendationResponse(item)
tenderRef := strings.TrimSpace(item.ProcedureRef)
if len(companyIDs) > 0 {
if isRecommendedTenderExcludedForAllCompanies(tender, tenderRef, excludedByCompany, companyIDs) {
continue
}
} else if isRecommendedTenderExcluded(tender, tenderRef, excluded) {
continue
}
if form.OnlyActiveDeadlines && !tender.IsRecommendable(now) {
continue
}
if len(form.Status) > 0 && !statusAllowed(tender.Status, form.Status) {
continue
}
out = append(out, item)
}
return out
}
func tenderFromRecommendationResponse(item TenderResponse) Tender {
var tender Tender
if id, err := bson.ObjectIDFromHex(strings.TrimSpace(item.ID)); err == nil {
tender.ID = id
}
tender.ContractFolderID = item.ContractFolderID
tender.NoticePublicationID = item.NoticePublicationID
tender.RelatedNoticePublicationIDs = append([]string(nil), item.RelatedNoticePublicationIDs...)
tender.Status = item.Status
tender.PublicationDate = item.PublicationDate
tender.SubmissionDeadline = item.SubmissionDeadline
tender.TenderDeadline = item.TenderDeadline
return tender
}
// RecommendedTendersPageCacheKeyPrefix is shared with company invalidation logic.
const RecommendedTendersPageCacheKeyPrefix = recommendedTendersPageCacheKeyPrefix
// RecommendedTendersPageCacheKey builds the Redis key for a cached recommendation page.
func RecommendedTendersPageCacheKey(companyID, language string) string {
return recommendedTendersPageCacheKey(companyID, language)
}