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:
2026-04-15 16:11:05 +08:00
parent 4d06938565
commit 1538a12042
28 changed files with 1316 additions and 634 deletions
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
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/messaging/rabbitmq"
sharedschedule "github.com/geo-platform/tenant-api/internal/shared/schedule"
@@ -19,12 +20,14 @@ const (
defaultScheduleDispatchInterval = 15 * time.Second
defaultScheduleDispatchTimeout = 30 * time.Second
defaultScheduleDispatchBatch = 20
scheduleCacheCleanupTimeout = 30 * time.Second
)
type ScheduleDispatchWorker struct {
pool *pgxpool.Pool
rabbitMQ *rabbitmq.Client
promptRuleService *tenantapp.PromptRuleGenerationService
cache sharedcache.Cache
logger *zap.Logger
interval time.Duration
timeout time.Duration
@@ -49,10 +52,16 @@ type dueScheduleTask struct {
ScheduledFor time.Time
}
type scheduleTaskCacheUpdate struct {
id int64
tenantID int64
}
func NewScheduleDispatchWorker(
pool *pgxpool.Pool,
rabbitMQClient *rabbitmq.Client,
promptRuleService *tenantapp.PromptRuleGenerationService,
cache sharedcache.Cache,
logger *zap.Logger,
cfg config.SchedulerConfig,
) *ScheduleDispatchWorker {
@@ -60,6 +69,7 @@ func NewScheduleDispatchWorker(
pool: pool,
rabbitMQ: rabbitMQClient,
promptRuleService: promptRuleService,
cache: cache,
logger: logger,
interval: schedulerDispatchInterval(cfg),
timeout: schedulerDispatchTimeout(cfg),
@@ -138,7 +148,7 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in
defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `
SELECT id, cron_expr, start_at, end_at, status
SELECT id, tenant_id, cron_expr, start_at, end_at, status
FROM schedule_tasks
WHERE deleted_at IS NULL
AND status = 'enabled'
@@ -154,18 +164,19 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in
now := time.Now()
count := 0
updates := make([]struct {
id int64
nextRunAt *time.Time
cacheUpdate scheduleTaskCacheUpdate
nextRunAt *time.Time
}, 0, w.batchSize)
for rows.Next() {
var (
id int64
tenantID int64
cronExpr string
status string
startAt pgtype.Timestamptz
endAt pgtype.Timestamptz
)
if err := rows.Scan(&id, &cronExpr, &startAt, &endAt, &status); err != nil {
if err := rows.Scan(&id, &tenantID, &cronExpr, &startAt, &endAt, &status); err != nil {
rows.Close()
return 0, err
}
@@ -183,10 +194,13 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in
}
updates = append(updates, struct {
id int64
nextRunAt *time.Time
cacheUpdate scheduleTaskCacheUpdate
nextRunAt *time.Time
}{
id: id,
cacheUpdate: scheduleTaskCacheUpdate{
id: id,
tenantID: tenantID,
},
nextRunAt: nextRunAt,
})
}
@@ -202,7 +216,7 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in
SET next_run_at = $1,
updated_at = NOW()
WHERE id = $2
`, update.nextRunAt, update.id); err != nil {
`, update.nextRunAt, update.cacheUpdate.id); err != nil {
return 0, err
}
count++
@@ -211,6 +225,11 @@ func (w *ScheduleDispatchWorker) hydrateMissingNextRuns(ctx context.Context) (in
if err := tx.Commit(ctx); err != nil {
return 0, err
}
cacheUpdates := make([]scheduleTaskCacheUpdate, 0, len(updates))
for _, update := range updates {
cacheUpdates = append(cacheUpdates, update.cacheUpdate)
}
w.invalidateScheduleTaskCaches(cacheUpdates)
return count, nil
}
@@ -240,8 +259,8 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc
now := time.Now()
tasks := make([]dueScheduleTask, 0, w.batchSize)
updates := make([]struct {
id int64
nextRun *time.Time
cacheUpdate scheduleTaskCacheUpdate
nextRun *time.Time
}, 0, w.batchSize)
for rows.Next() {
var (
@@ -293,10 +312,13 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc
}
updates = append(updates, struct {
id int64
nextRun *time.Time
cacheUpdate scheduleTaskCacheUpdate
nextRun *time.Time
}{
id: task.ID,
cacheUpdate: scheduleTaskCacheUpdate{
id: task.ID,
tenantID: task.TenantID,
},
nextRun: nextRun,
})
@@ -316,7 +338,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc
SET next_run_at = $1,
updated_at = NOW()
WHERE id = $2
`, update.nextRun, update.id); err != nil {
`, update.nextRun, update.cacheUpdate.id); err != nil {
return nil, err
}
}
@@ -324,6 +346,11 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context) ([]dueSc
if err := tx.Commit(ctx); err != nil {
return nil, err
}
cacheUpdates := make([]scheduleTaskCacheUpdate, 0, len(updates))
for _, update := range updates {
cacheUpdates = append(cacheUpdates, update.cacheUpdate)
}
w.invalidateScheduleTaskCaches(cacheUpdates)
return tasks, nil
}
@@ -450,6 +477,20 @@ func schedulerDispatchConcurrency(cfg config.SchedulerConfig) int {
return 1
}
func (w *ScheduleDispatchWorker) invalidateScheduleTaskCaches(updates []scheduleTaskCacheUpdate) {
if w == nil || w.cache == nil || len(updates) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), scheduleCacheCleanupTimeout)
defer cancel()
for _, update := range updates {
taskID := update.id
tenantapp.InvalidateScheduleTaskCaches(ctx, w.cache, update.tenantID, &taskID)
}
}
func timePtrFromTimestamp(value pgtype.Timestamptz) *time.Time {
if !value.Valid {
return nil