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:
@@ -14,9 +14,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||
"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/contentstats"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
@@ -28,12 +30,19 @@ type ArticleService struct {
|
||||
pool *pgxpool.Pool
|
||||
auditLogs *auditlog.AsyncWriter
|
||||
objectStorage objectstorage.Client
|
||||
cache sharedcache.Cache
|
||||
cacheGroup singleflight.Group
|
||||
}
|
||||
|
||||
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
|
||||
return &ArticleService{pool: pool, auditLogs: auditLogs, objectStorage: objectStorage}
|
||||
}
|
||||
|
||||
func (s *ArticleService) WithCache(c sharedcache.Cache) *ArticleService {
|
||||
s.cache = c
|
||||
return s
|
||||
}
|
||||
|
||||
const maxArticleImageSizeBytes = 10 * 1024 * 1024
|
||||
|
||||
var supportedArticleImageExtensions = map[string]string{
|
||||
@@ -84,6 +93,47 @@ type ArticleListResponse struct {
|
||||
|
||||
func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*ArticleListResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, articleListCacheKey(actor.TenantID, params), defaultCacheTTL(), func(loadCtx context.Context) (*ArticleListResponse, error) {
|
||||
return s.loadArticles(loadCtx, actor.TenantID, params)
|
||||
})
|
||||
}
|
||||
|
||||
type ArticleDetailResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
GenerationMode *string `json:"generation_mode"`
|
||||
Platforms []string `json:"platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
HTMLContent *string `json:"html_content"`
|
||||
MarkdownContent *string `json:"markdown_content"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
VersionNo *int `json:"version_no"`
|
||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, articleDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*ArticleDetailResponse, bool, error) {
|
||||
return s.loadArticleDetail(loadCtx, actor.TenantID, id)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found || record == nil {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, params ArticleListParams) (*ArticleListResponse, error) {
|
||||
sourceType := params.SourceType
|
||||
|
||||
if params.Page < 1 {
|
||||
@@ -94,9 +144,8 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
}
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
|
||||
// Count
|
||||
var total int64
|
||||
countArgs := []interface{}{actor.TenantID}
|
||||
countArgs := []interface{}{tenantID}
|
||||
countQuery := `SELECT COUNT(*) FROM articles a
|
||||
LEFT JOIN article_versions av ON av.id = a.current_version_id
|
||||
LEFT JOIN prompt_rules pr ON pr.id = a.prompt_rule_id
|
||||
@@ -160,7 +209,6 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to count articles")
|
||||
}
|
||||
|
||||
// List
|
||||
listQuery := `SELECT a.id, a.source_type, a.template_id, a.prompt_rule_id, a.generate_status, a.publish_status, a.created_at,
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')), gt.error_message, COALESCE(av.word_count, 0), av.source_label, t.template_name, pr.name, a.wizard_state_json, gt.input_params_json
|
||||
FROM articles a
|
||||
@@ -175,7 +223,7 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
LIMIT 1
|
||||
) gt ON true
|
||||
WHERE a.tenant_id = $1 AND a.deleted_at IS NULL`
|
||||
listArgs := []interface{}{actor.TenantID}
|
||||
listArgs := []interface{}{tenantID}
|
||||
listIdx := 2
|
||||
|
||||
if params.GenerateStatus != nil {
|
||||
@@ -233,7 +281,7 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ArticleListItem
|
||||
items := make([]ArticleListItem, 0)
|
||||
for rows.Next() {
|
||||
var item ArticleListItem
|
||||
var dbSourceType string
|
||||
@@ -248,40 +296,13 @@ func (s *ArticleService) List(ctx context.Context, params ArticleListParams) (*A
|
||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
items = append(items, item)
|
||||
}
|
||||
if items == nil {
|
||||
items = []ArticleListItem{}
|
||||
}
|
||||
|
||||
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
|
||||
}
|
||||
|
||||
type ArticleDetailResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
TemplateID *int64 `json:"template_id"`
|
||||
TemplateName *string `json:"template_name"`
|
||||
GenerationMode *string `json:"generation_mode"`
|
||||
Platforms []string `json:"platforms"`
|
||||
CoverAssetURL *string `json:"cover_asset_url"`
|
||||
GenerateStatus string `json:"generate_status"`
|
||||
PublishStatus string `json:"publish_status"`
|
||||
Title *string `json:"title"`
|
||||
HTMLContent *string `json:"html_content"`
|
||||
MarkdownContent *string `json:"markdown_content"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
VersionNo *int `json:"version_no"`
|
||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
var d ArticleDetailResponse
|
||||
func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articleID int64) (*ArticleDetailResponse, bool, error) {
|
||||
var item ArticleDetailResponse
|
||||
var dbSourceType string
|
||||
var currentVersionID *int64
|
||||
|
||||
var wizardStateJSON []byte
|
||||
var inputParamsJSON []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
@@ -299,29 +320,33 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
|
||||
LIMIT 1
|
||||
) gt ON true
|
||||
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL
|
||||
`, id, actor.TenantID).Scan(&d.ID, &dbSourceType, &d.TemplateID, ¤tVersionID, &d.GenerateStatus, &d.PublishStatus, &d.CreatedAt,
|
||||
&d.Title, &d.WordCount, &d.SourceLabel, &d.VersionNo, &d.TemplateName, &d.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
|
||||
`, articleID, tenantID).Scan(&item.ID, &dbSourceType, &item.TemplateID, ¤tVersionID, &item.GenerateStatus, &item.PublishStatus, &item.CreatedAt,
|
||||
&item.Title, &item.WordCount, &item.SourceLabel, &item.VersionNo, &item.TemplateName, &item.GenerationErrorMessage, &wizardStateJSON, &inputParamsJSON)
|
||||
if err != nil {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
d.SourceType = dbSourceType
|
||||
if currentVersionID != nil {
|
||||
content, err := repository.LoadArticleVersionContent(ctx, s.pool, d.ID, *currentVersionID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
d.HTMLContent = content.HTMLContent
|
||||
d.MarkdownContent = content.MarkdownContent
|
||||
return nil, false, response.ErrInternal(50010, "query_failed", "failed to fetch article")
|
||||
}
|
||||
|
||||
item.SourceType = dbSourceType
|
||||
if currentVersionID != nil {
|
||||
content, err := repository.LoadArticleVersionContent(ctx, s.pool, item.ID, *currentVersionID)
|
||||
if err != nil {
|
||||
return nil, false, response.ErrInternal(50011, "article_version_reconstruct_failed", "failed to reconstruct article version")
|
||||
}
|
||||
item.HTMLContent = content.HTMLContent
|
||||
item.MarkdownContent = content.MarkdownContent
|
||||
}
|
||||
if len(wizardStateJSON) > 0 {
|
||||
d.WizardState = map[string]interface{}{}
|
||||
_ = json.Unmarshal(wizardStateJSON, &d.WizardState)
|
||||
item.WizardState = map[string]interface{}{}
|
||||
_ = json.Unmarshal(wizardStateJSON, &item.WizardState)
|
||||
}
|
||||
d.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||
d.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
d.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||
d.WordCount = resolveWordCount(d.MarkdownContent, d.WordCount)
|
||||
return &d, nil
|
||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||
item.WordCount = resolveWordCount(item.MarkdownContent, item.WordCount)
|
||||
return &item, true, nil
|
||||
}
|
||||
|
||||
type CreateArticleRequest struct {
|
||||
@@ -374,6 +399,7 @@ func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (
|
||||
return nil, response.ErrInternal(50014, "create_failed", "failed to commit article creation")
|
||||
}
|
||||
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||
return s.Detail(ctx, articleID)
|
||||
}
|
||||
|
||||
@@ -473,6 +499,7 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
||||
return nil, response.ErrInternal(50012, "update_failed", "failed to commit article update")
|
||||
}
|
||||
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &id)
|
||||
return s.Detail(ctx, id)
|
||||
}
|
||||
|
||||
@@ -598,6 +625,7 @@ func (s *ArticleService) Delete(ctx context.Context, id int64) error {
|
||||
Result: &result,
|
||||
})
|
||||
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &id)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user