3002935b76
continuous-integration/drone/push Build is passing
- Increased the cache duration for dashboard statistics from 60 seconds to 5 minutes, improving data freshness and reducing load on the backend. - Introduced a stale cache mechanism that allows retrieval of stale statistics while refreshing them in the background, enhancing user experience by providing quicker access to data. - Updated the statistics repository to handle the new caching logic, ensuring accurate and timely statistics reporting. - Added tests to validate the new caching behavior and ensure the integrity of statistics retrieval. This update optimizes the dashboard's performance and responsiveness by improving the caching strategy for statistics.
403 lines
11 KiB
Go
403 lines
11 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"tm/internal/notice"
|
|
"tm/pkg/ai_summarizer"
|
|
orm "tm/pkg/mongo"
|
|
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
const noticesCollectionName = "notices"
|
|
|
|
// scrapedScopeCacheTTL avoids listing every MinIO object on each statistics request while
|
|
// keeping counts aligned with the tender panel (MinIO is the source of truth for scraped docs).
|
|
const scrapedScopeCacheTTL = 5 * time.Minute
|
|
|
|
const (
|
|
scrapedScopeResolveTimeout = 2 * time.Minute
|
|
statisticsQueryTimeout = 2 * time.Minute
|
|
maxScrapedFolderInClause = 5000
|
|
)
|
|
|
|
// scrapedTendersScope is the resolved Mongo filter for tenders with scraped documents.
|
|
// zero is set when MinIO reports no procedure folders; callers must return zero without querying.
|
|
type scrapedTendersScope struct {
|
|
match bson.M
|
|
zero bool
|
|
fromMinIO bool
|
|
dailyCounts map[string]int64 // non-nil when scope was resolved from MinIO
|
|
totalTenderCount int64 // >0 when MinIO procedure count should be used instead of Mongo Count
|
|
}
|
|
|
|
func (r *repository) Statistics(ctx context.Context, days int, startUnix int64) (*StatisticsReportResponse, error) {
|
|
queryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), statisticsQueryTimeout)
|
|
defer cancel()
|
|
|
|
now := time.Now().UTC()
|
|
endDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
startDay := endDay.AddDate(0, 0, -(days - 1))
|
|
|
|
var (
|
|
scrapedTED map[string]int64
|
|
scrapedDocuments map[string]int64
|
|
translatedNotices map[string]int64
|
|
totalDocuments int64
|
|
totalTranslated int64
|
|
scrapedScope scrapedTendersScope
|
|
)
|
|
|
|
g, gctx := errgroup.WithContext(queryCtx)
|
|
|
|
g.Go(func() error {
|
|
// Use created_at (first ingest time). processing_metadata.scraped_at is reset on
|
|
// notice refresh merges, which would collapse historical scrapes onto the latest day.
|
|
counts, err := r.dailyCountByTimestamp(gctx, noticesCollectionName, bson.M{
|
|
"source": notice.TenderSourceTEDScraper,
|
|
"created_at": bson.M{"$gte": startUnix, "$gt": 0},
|
|
}, "created_at")
|
|
if err != nil {
|
|
return fmt.Errorf("scraped ted per day: %w", err)
|
|
}
|
|
scrapedTED = counts
|
|
return nil
|
|
})
|
|
|
|
g.Go(func() error {
|
|
counts, err := r.translatedNoticesPerDay(gctx, startDay, endDay)
|
|
if err != nil {
|
|
return fmt.Errorf("translated notices per day: %w", err)
|
|
}
|
|
translatedNotices = counts
|
|
return nil
|
|
})
|
|
|
|
g.Go(func() error {
|
|
total, err := r.metricsCounter.Get(gctx, orm.AITranslationSuccessCounterKey)
|
|
if err != nil {
|
|
return fmt.Errorf("total translated tenders: %w", err)
|
|
}
|
|
totalTranslated = total
|
|
return nil
|
|
})
|
|
|
|
// Resolve scraped-document scope from MinIO (cached) in parallel with Mongo queries.
|
|
g.Go(func() error {
|
|
scope, err := r.cachedScrapedTendersScope(gctx)
|
|
if err != nil {
|
|
return fmt.Errorf("scraped tenders scope: %w", err)
|
|
}
|
|
scrapedScope = scope
|
|
return nil
|
|
})
|
|
|
|
if err := g.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
g2, gctx2 := errgroup.WithContext(queryCtx)
|
|
|
|
g2.Go(func() error {
|
|
counts, err := r.scrapedDocumentsPerDay(gctx2, startUnix, scrapedScope)
|
|
if err != nil {
|
|
return fmt.Errorf("scraped documents per day: %w", err)
|
|
}
|
|
scrapedDocuments = counts
|
|
return nil
|
|
})
|
|
|
|
g2.Go(func() error {
|
|
total, err := r.totalScrapedTenders(gctx2, scrapedScope)
|
|
if err != nil {
|
|
return fmt.Errorf("total scraped tenders: %w", err)
|
|
}
|
|
totalDocuments = total
|
|
return nil
|
|
})
|
|
|
|
if err := g2.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &StatisticsReportResponse{
|
|
Days: days,
|
|
GeneratedAt: now.Unix(),
|
|
Daily: StatisticsDailySeries{
|
|
ScrapedTED: fillTrendSeries(days, scrapedTED),
|
|
ScrapedDocuments: fillTrendSeries(days, scrapedDocuments),
|
|
TranslatedNotices: fillTrendSeries(days, translatedNotices),
|
|
},
|
|
Totals: StatisticsLifetimeTotals{
|
|
ScrapedDocuments: totalDocuments,
|
|
TranslatedTenders: totalTranslated,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (r *repository) dailyCountByTimestamp(ctx context.Context, collection string, match bson.M, timestampField string) (map[string]int64, error) {
|
|
pipeline := mongodriver.Pipeline{
|
|
{{Key: "$match", Value: match}},
|
|
{{Key: "$addFields", Value: bson.M{
|
|
"metric_ts": normalizeTimestampExpr(timestampField),
|
|
}}},
|
|
{{Key: "$group", Value: bson.M{
|
|
"_id": bson.M{
|
|
"$dateToString": bson.M{
|
|
"format": "%Y-%m-%d",
|
|
"date": bson.M{"$toDate": bson.M{"$multiply": bson.A{"$metric_ts", 1000}}},
|
|
"timezone": "UTC",
|
|
},
|
|
},
|
|
"count": bson.M{"$sum": 1},
|
|
}}},
|
|
}
|
|
|
|
cursor, err := r.mongoManager.GetCollection(collection).Aggregate(ctx, pipeline)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cursor.Close(ctx)
|
|
|
|
return decodeDailyCounts(ctx, cursor)
|
|
}
|
|
|
|
func (r *repository) scrapedDocumentsPerDay(ctx context.Context, startUnix int64, scope scrapedTendersScope) (map[string]int64, error) {
|
|
if scope.zero {
|
|
return map[string]int64{}, nil
|
|
}
|
|
|
|
if scope.dailyCounts != nil {
|
|
return filterDailyCountsSince(scope.dailyCounts, startUnix), nil
|
|
}
|
|
|
|
return r.scrapedDocumentsPerDayFromMongo(ctx, startUnix, scope.match)
|
|
}
|
|
|
|
func filterDailyCountsSince(counts map[string]int64, startUnix int64) map[string]int64 {
|
|
if len(counts) == 0 {
|
|
return map[string]int64{}
|
|
}
|
|
startDay := time.Unix(startUnix, 0).UTC().Format("2006-01-02")
|
|
filtered := make(map[string]int64)
|
|
for date, count := range counts {
|
|
if date >= startDay {
|
|
filtered[date] = count
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (r *repository) scrapedDocumentsPerDayFromMongo(ctx context.Context, startUnix int64, match bson.M) (map[string]int64, error) {
|
|
pipeline := mongodriver.Pipeline{
|
|
{{Key: "$match", Value: match}},
|
|
{{Key: "$unwind", Value: "$scraped_documents"}},
|
|
{{Key: "$addFields", Value: bson.M{
|
|
"metric_ts": bson.M{
|
|
"$cond": bson.A{
|
|
bson.M{"$gt": bson.A{"$scraped_documents.scraped_at", 0}},
|
|
"$scraped_documents.scraped_at",
|
|
"$processing_metadata.documents_scraped_at",
|
|
},
|
|
},
|
|
}}},
|
|
{{Key: "$addFields", Value: bson.M{
|
|
"metric_ts": normalizeTimestampExpr("metric_ts"),
|
|
}}},
|
|
{{Key: "$match", Value: bson.M{
|
|
"metric_ts": bson.M{"$gte": startUnix, "$gt": 0},
|
|
}}},
|
|
{{Key: "$group", Value: bson.M{
|
|
"_id": bson.M{
|
|
"$dateToString": bson.M{
|
|
"format": "%Y-%m-%d",
|
|
"date": bson.M{"$toDate": bson.M{"$multiply": bson.A{"$metric_ts", 1000}}},
|
|
"timezone": "UTC",
|
|
},
|
|
},
|
|
"count": bson.M{"$sum": 1},
|
|
}}},
|
|
}
|
|
|
|
cursor, err := r.mongoManager.GetCollection(tenderCollectionName()).Aggregate(ctx, pipeline)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cursor.Close(ctx)
|
|
|
|
return decodeDailyCounts(ctx, cursor)
|
|
}
|
|
|
|
func (r *repository) translatedNoticesPerDay(ctx context.Context, startDay, endDay time.Time) (map[string]int64, error) {
|
|
return r.metricsCounter.GetDailyCounts(ctx, startDay, endDay)
|
|
}
|
|
|
|
func (r *repository) totalScrapedTenders(ctx context.Context, scope scrapedTendersScope) (int64, error) {
|
|
if scope.zero {
|
|
return 0, nil
|
|
}
|
|
if scope.totalTenderCount > 0 {
|
|
return scope.totalTenderCount, nil
|
|
}
|
|
|
|
return r.ormRepo.Count(ctx, scope.match)
|
|
}
|
|
|
|
func mongoScrapedTendersScope() scrapedTendersScope {
|
|
return scrapedTendersScope{
|
|
match: bson.M{
|
|
"processing_metadata.documents_scraped": true,
|
|
"scraped_documents": bson.M{"$exists": true, "$ne": bson.A{}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *repository) cachedScrapedTendersScope(ctx context.Context) (scrapedTendersScope, error) {
|
|
now := time.Now()
|
|
|
|
r.scrapedScopeMu.Lock()
|
|
if r.scrapedScopeCached && now.Before(r.scrapedScopeExpiry) {
|
|
scope := r.scrapedScope
|
|
r.scrapedScopeMu.Unlock()
|
|
return scope, nil
|
|
}
|
|
r.scrapedScopeMu.Unlock()
|
|
|
|
v, err, _ := r.scrapedScopeGroup.Do("scraped-scope", func() (interface{}, error) {
|
|
r.scrapedScopeMu.Lock()
|
|
if r.scrapedScopeCached && time.Now().Before(r.scrapedScopeExpiry) {
|
|
scope := r.scrapedScope
|
|
r.scrapedScopeMu.Unlock()
|
|
return scope, nil
|
|
}
|
|
r.scrapedScopeMu.Unlock()
|
|
|
|
resolveCtx, cancel := context.WithTimeout(context.Background(), scrapedScopeResolveTimeout)
|
|
defer cancel()
|
|
|
|
scope, resolveErr := r.resolveScrapedTendersScope(resolveCtx)
|
|
if resolveErr != nil {
|
|
r.logger.Warn("MinIO scraped scope unavailable, using Mongo fallback for dashboard statistics", map[string]interface{}{
|
|
"error": resolveErr.Error(),
|
|
})
|
|
return mongoScrapedTendersScope(), nil
|
|
}
|
|
|
|
r.scrapedScopeMu.Lock()
|
|
r.scrapedScope = scope
|
|
r.scrapedScopeExpiry = time.Now().Add(scrapedScopeCacheTTL)
|
|
r.scrapedScopeCached = true
|
|
r.scrapedScopeMu.Unlock()
|
|
|
|
return scope, nil
|
|
})
|
|
if err != nil {
|
|
return scrapedTendersScope{}, err
|
|
}
|
|
|
|
return v.(scrapedTendersScope), nil
|
|
}
|
|
|
|
func (r *repository) resolveScrapedTendersScope(ctx context.Context) (scrapedTendersScope, error) {
|
|
procedures, dailyCounts, err := r.listScrapedProcedures(ctx)
|
|
if err != nil {
|
|
return scrapedTendersScope{}, err
|
|
}
|
|
|
|
folderIDs := folderIDsFromProcedures(procedures)
|
|
if len(folderIDs) > 0 {
|
|
totalTenders := int64(len(folderIDs))
|
|
if len(folderIDs) > maxScrapedFolderInClause {
|
|
r.logger.Warn("Too many MinIO procedure folders for dashboard statistics, using MinIO counts without Mongo $in filter", map[string]interface{}{
|
|
"folder_count": len(folderIDs),
|
|
"max_in_clause": maxScrapedFolderInClause,
|
|
})
|
|
return scrapedTendersScope{
|
|
fromMinIO: true,
|
|
dailyCounts: dailyCounts,
|
|
totalTenderCount: totalTenders,
|
|
}, nil
|
|
}
|
|
return scrapedTendersScope{
|
|
match: bson.M{"contract_folder_id": bson.M{"$in": folderIDs}},
|
|
fromMinIO: true,
|
|
dailyCounts: dailyCounts,
|
|
totalTenderCount: totalTenders,
|
|
}, nil
|
|
}
|
|
return mongoScrapedTendersScope(), nil
|
|
}
|
|
|
|
func (r *repository) listScrapedProcedures(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, map[string]int64, error) {
|
|
if r.procedureLister == nil {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
if scanner, ok := r.procedureLister.(scrapedDocumentsScanner); ok {
|
|
return scanner.ScanScrapedDocuments(ctx)
|
|
}
|
|
|
|
procedures, err := r.procedureLister.ListProceduresWithDocuments(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return procedures, nil, nil
|
|
}
|
|
|
|
func folderIDsFromProcedures(procedures []ai_summarizer.ProcedureDocumentsSummary) []string {
|
|
folderIDs := make([]string, 0, len(procedures))
|
|
seen := make(map[string]struct{}, len(procedures))
|
|
for _, proc := range procedures {
|
|
if proc.DocumentCount <= 0 {
|
|
continue
|
|
}
|
|
id := strings.TrimSpace(proc.ContractFolderID)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
folderIDs = append(folderIDs, id)
|
|
}
|
|
return folderIDs
|
|
}
|
|
|
|
func (r *repository) scrapedDocumentsFolderIDs(ctx context.Context) ([]string, error) {
|
|
if r.procedureLister == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
procedures, _, err := r.listScrapedProcedures(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return folderIDsFromProcedures(procedures), nil
|
|
}
|
|
|
|
func decodeDailyCounts(ctx context.Context, cursor *mongodriver.Cursor) (map[string]int64, error) {
|
|
counts := make(map[string]int64)
|
|
for cursor.Next(ctx) {
|
|
var row bson.M
|
|
if err := cursor.Decode(&row); err != nil {
|
|
return nil, err
|
|
}
|
|
date, ok := row["_id"].(string)
|
|
if !ok || date == "" {
|
|
continue
|
|
}
|
|
counts[date] = aggregateCount(row, "count")
|
|
}
|
|
if err := cursor.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return counts, nil
|
|
}
|