feat(cache): add read-through cache layer across all app services
Introduce a generic read-through caching infrastructure and wire it into all major tenant app services to reduce database load on hot read paths. Key changes: - Add `DeletePrefix` to Cache interface with memory (prefix scan) and Redis (SCAN + DEL) implementations - New `readthrough.go`: generic `LoadJSON` / `LoadJSONWithEmpty` helpers backed by singleflight to prevent cache stampedes; supports jittered TTL - New `cache_support.go`: centralized cache key builders and invalidation helpers for all entities (workspace, brand, prompt rules, schedule tasks, articles) - Wire optional cache into ArticleService, BrandService, WorkspaceService, PromptRuleService, ScheduleTaskService, TemplateService, MediaService, PromptGenerateService via `WithCache()` builder pattern - ScheduleDispatchWorker invalidates schedule task cache after dispatching - ArticleService gains a new `Detail` endpoint with empty-result caching - Update cmd entrypoints and transport handlers to propagate cache
This commit is contained in:
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
@@ -29,6 +31,7 @@ type TemplateService struct {
|
||||
streamEnabled bool
|
||||
articleTimeout time.Duration
|
||||
maxOutputTokens int64
|
||||
cache sharedcache.Cache
|
||||
}
|
||||
|
||||
type generationJob struct {
|
||||
@@ -75,6 +78,11 @@ func NewTemplateService(
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *TemplateService) WithCache(c sharedcache.Cache) *TemplateService {
|
||||
s.cache = c
|
||||
return s
|
||||
}
|
||||
|
||||
type TemplateListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
@@ -125,7 +133,7 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
|
||||
actor := auth.MustActor(ctx)
|
||||
record, err := s.templates.GetTemplateByID(ctx, id, actor.TenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
return nil, mapTemplateLookupError(err)
|
||||
}
|
||||
|
||||
detail := &TemplateDetail{
|
||||
@@ -158,6 +166,13 @@ func canViewPromptTemplate(record *repository.TemplateRecord, actorTenantID int6
|
||||
return *record.TenantID == actorTenantID
|
||||
}
|
||||
|
||||
func mapTemplateLookupError(err error) error {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func effectivePromptVisibility(record *repository.TemplateRecord, actorTenantID int64) string {
|
||||
if canViewPromptTemplate(record, actorTenantID) {
|
||||
return "visible"
|
||||
@@ -192,7 +207,7 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S
|
||||
actor := auth.MustActor(ctx)
|
||||
|
||||
if _, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID); err != nil {
|
||||
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
return nil, mapTemplateLookupError(err)
|
||||
}
|
||||
|
||||
stateJSON, err := json.Marshal(normalizeWizardState(req.WizardState, req.CurrentStep))
|
||||
@@ -210,6 +225,7 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "draft_create_failed", "failed to create article draft")
|
||||
}
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||
return &SaveDraftResponse{ArticleID: articleID}, nil
|
||||
}
|
||||
|
||||
@@ -232,6 +248,7 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S
|
||||
return nil, response.ErrConflict(40913, "draft_not_editable", "draft is not editable")
|
||||
}
|
||||
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||
return &SaveDraftResponse{ArticleID: articleID}, nil
|
||||
}
|
||||
|
||||
@@ -240,7 +257,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
|
||||
templateRecord, err := s.templates.GetTemplateByID(ctx, templateID, actor.TenantID)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
|
||||
return nil, mapTemplateLookupError(err)
|
||||
}
|
||||
if err := s.llm.Validate(); err != nil {
|
||||
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||
@@ -358,6 +375,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||
|
||||
job := generationJob{
|
||||
OperatorID: actor.UserID,
|
||||
@@ -481,9 +499,12 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
s.failGeneration(ctx, job, title, "persist_begin_tx", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
defer rollbackGenerationTx(tx)
|
||||
|
||||
failWithRollback := func(stage string, err error) {
|
||||
rollbackGenerationTx(tx)
|
||||
s.failGeneration(ctx, job, title, stage, err)
|
||||
}
|
||||
|
||||
articleRepo := repository.NewArticleRepository(tx)
|
||||
quotaRepo := repository.NewQuotaRepository(tx)
|
||||
@@ -499,16 +520,16 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
SourceLabel: result.Model,
|
||||
})
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_create_version", err)
|
||||
failWithRollback("persist_create_version", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_current_version", err)
|
||||
failWithRollback("persist_current_version", err)
|
||||
return
|
||||
}
|
||||
if err := articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed"); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_article_status", err)
|
||||
failWithRollback("persist_article_status", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -520,18 +541,21 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
StartedAt: &now,
|
||||
CompletedAt: &completed,
|
||||
}); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_task_status", err)
|
||||
failWithRollback("persist_task_status", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID); err != nil {
|
||||
s.failGeneration(ctx, job, title, "confirm_reservation", err)
|
||||
failWithRollback("confirm_reservation", err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
s.failGeneration(ctx, job, title, "persist_commit", err)
|
||||
failWithRollback("persist_commit", err)
|
||||
return
|
||||
}
|
||||
cleanupCtx, cancel := newGenerationCleanupContext()
|
||||
defer cancel()
|
||||
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
|
||||
|
||||
if s.streamEnabled {
|
||||
s.streams.Complete(job.ArticleID, job.TaskID, title, content)
|
||||
@@ -539,6 +563,9 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
}
|
||||
|
||||
func (s *TemplateService) failGeneration(ctx context.Context, job generationJob, title, stage string, genErr error) {
|
||||
cleanupCtx, cancel := newGenerationCleanupContext()
|
||||
defer cancel()
|
||||
|
||||
errMsg := formatGenerationFailure(stage, genErr)
|
||||
now := time.Now()
|
||||
|
||||
@@ -546,21 +573,21 @@ func (s *TemplateService) failGeneration(ctx context.Context, job generationJob,
|
||||
articleRepo := repository.NewArticleRepository(s.pool)
|
||||
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||
|
||||
_ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
_ = auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "failed",
|
||||
ErrorMessage: &errMsg,
|
||||
CompletedAt: &now,
|
||||
})
|
||||
_ = articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "failed")
|
||||
_ = articleRepo.UpdateArticleGenerateStatus(cleanupCtx, job.ArticleID, job.TenantID, "failed")
|
||||
|
||||
balance, balanceErr := quotaRepo.GetCurrentBalance(ctx, job.TenantID, "article_generation")
|
||||
balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation")
|
||||
if balanceErr == nil {
|
||||
reason := "generation_refund"
|
||||
referenceType := "generation_task"
|
||||
taskReferenceID := job.TaskID
|
||||
_, _ = quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
_, _ = quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
|
||||
TenantID: job.TenantID,
|
||||
OperatorID: job.OperatorID,
|
||||
QuotaType: "article_generation",
|
||||
@@ -572,7 +599,8 @@ func (s *TemplateService) failGeneration(ctx context.Context, job generationJob,
|
||||
})
|
||||
}
|
||||
|
||||
_ = quotaRepo.RefundReservation(ctx, job.ReservationID, job.TenantID)
|
||||
_ = quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID)
|
||||
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
|
||||
|
||||
if s.streamEnabled {
|
||||
s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg)
|
||||
|
||||
Reference in New Issue
Block a user