diff --git a/internal/tender/documents_scraped_filter.go b/internal/tender/documents_scraped_filter.go index 8265fbc..899c0cc 100644 --- a/internal/tender/documents_scraped_filter.go +++ b/internal/tender/documents_scraped_filter.go @@ -12,7 +12,8 @@ import ( const ( // contractFoldersWithDocsCacheTTL avoids scanning MinIO on every documents_scraped list request. - contractFoldersWithDocsCacheTTL = 60 * time.Second + // Kept >= resolve timeout so a slow scan is not immediately invalidated for waiters. + contractFoldersWithDocsCacheTTL = 15 * time.Minute // contractFoldersWithDocsResolveTimeout bounds a single MinIO procedure scan. contractFoldersWithDocsResolveTimeout = 3 * time.Minute ) @@ -65,7 +66,9 @@ func (s *tenderService) cachedContractFolderIDsWithDocuments(ctx context.Context } s.contractFoldersCacheMu.Unlock() - resolveCtx, cancel := context.WithTimeout(ctx, contractFoldersWithDocsResolveTimeout) + // Detach from the caller's cancellation so a client disconnect or proxy timeout + // does not abort the shared singleflight scan for every waiter. + resolveCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), contractFoldersWithDocsResolveTimeout) defer cancel() procedures, listErr := s.listProceduresWithDocuments(resolveCtx) diff --git a/internal/tender/documents_scraped_filter_test.go b/internal/tender/documents_scraped_filter_test.go index 33e8eaa..f208593 100644 --- a/internal/tender/documents_scraped_filter_test.go +++ b/internal/tender/documents_scraped_filter_test.go @@ -29,7 +29,9 @@ func (m *mockProcedureDocumentLister) ListProceduresWithDocuments(context.Contex } type stubProcedureDocumentStorage struct { - lister *mockProcedureDocumentLister + lister interface { + ListProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error) + } } func (s stubProcedureDocumentStorage) GetSummaryFromStorage(context.Context, string, string) (string, error) { @@ -156,6 +158,64 @@ func TestContractFolderIDsFromProceduresDedupesAndSkipsEmpty(t *testing.T) { } } +type scanStartedProcedureLister struct { + mockProcedureDocumentLister + started chan struct{} +} + +func (m *scanStartedProcedureLister) ListProceduresWithDocuments(ctx context.Context) ([]ai_summarizer.ProcedureDocumentsSummary, error) { + if m.started != nil { + select { + case <-m.started: + default: + close(m.started) + } + } + time.Sleep(50 * time.Millisecond) + if err := ctx.Err(); err != nil { + return nil, err + } + return m.mockProcedureDocumentLister.ListProceduresWithDocuments(ctx) +} + +func TestCachedContractFolderIDsSurvivesCallerCancellation(t *testing.T) { + started := make(chan struct{}) + lister := &scanStartedProcedureLister{ + mockProcedureDocumentLister: mockProcedureDocumentLister{ + procedures: []ai_summarizer.ProcedureDocumentsSummary{ + {ContractFolderID: "folder-1", DocumentCount: 1}, + }, + }, + started: started, + } + svc := &tenderService{aiSummarizerStorage: stubProcedureDocumentStorage{lister: lister}} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + var ids []string + go func() { + var err error + ids, err = svc.cachedContractFolderIDsWithDocuments(ctx) + errCh <- err + }() + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("MinIO scan did not start") + } + cancel() + + if err := <-errCh; err != nil { + t.Fatalf("expected shared scan to finish despite caller cancel: %v", err) + } + if len(ids) != 1 || ids[0] != "folder-1" { + t.Fatalf("unexpected folder ids: %#v", ids) + } +} + func TestContractFoldersWithDocsCacheExpires(t *testing.T) { lister := &mockProcedureDocumentLister{ procedures: []ai_summarizer.ProcedureDocumentsSummary{