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:
@@ -11,6 +11,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 +30,7 @@ type PromptRuleGenerationService struct {
|
||||
streamEnabled bool
|
||||
articleTimeout time.Duration
|
||||
maxOutputTokens int64
|
||||
cache sharedcache.Cache
|
||||
}
|
||||
|
||||
type promptRuleGenerationJob struct {
|
||||
@@ -131,6 +133,11 @@ func NewPromptRuleGenerationService(
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) WithCache(c sharedcache.Cache) *PromptRuleGenerationService {
|
||||
s.cache = c
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req GenerateFromRuleRequest) (*GenerateFromRuleResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
extra := clonePromptRuleExtraParams(req.ExtraParams)
|
||||
@@ -369,6 +376,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to commit generation task")
|
||||
}
|
||||
invalidateArticleCaches(ctx, s.cache, input.TenantID, &articleID)
|
||||
|
||||
job := promptRuleGenerationJob{
|
||||
OperatorID: operatorID,
|
||||
@@ -488,9 +496,12 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
|
||||
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)
|
||||
@@ -506,16 +517,16 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
|
||||
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
|
||||
}
|
||||
|
||||
@@ -527,18 +538,21 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
|
||||
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)
|
||||
@@ -546,6 +560,9 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
|
||||
}
|
||||
|
||||
func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job promptRuleGenerationJob, title, stage string, genErr error) {
|
||||
cleanupCtx, cancel := newGenerationCleanupContext()
|
||||
defer cancel()
|
||||
|
||||
errMsg := formatGenerationFailure(stage, genErr)
|
||||
now := time.Now()
|
||||
failureTitle := strings.TrimSpace(extractString(job.InputParams, "task_name"))
|
||||
@@ -557,16 +574,16 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr
|
||||
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")
|
||||
if failureTitle != "" {
|
||||
_, _ = s.pool.Exec(ctx, `
|
||||
_, _ = s.pool.Exec(cleanupCtx, `
|
||||
UPDATE articles
|
||||
SET wizard_state_json = jsonb_set(COALESCE(wizard_state_json, '{}'::jsonb), '{title}', to_jsonb($1::text), true),
|
||||
updated_at = NOW()
|
||||
@@ -574,12 +591,12 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr
|
||||
`, failureTitle, job.ArticleID, job.TenantID)
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -591,7 +608,8 @@ func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job pr
|
||||
})
|
||||
}
|
||||
|
||||
_ = 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, failureTitle, errMsg)
|
||||
|
||||
Reference in New Issue
Block a user