harden article generation reliability
Deployment Config CI / Deployment Config (push) Successful in 24s
Backend CI / Backend (push) Successful in 14m12s

This commit is contained in:
2026-05-05 23:43:10 +08:00
parent 3c912949e4
commit 65e9b7e293
24 changed files with 1259 additions and 52 deletions
@@ -32,6 +32,13 @@ type generationTaskRecoveryConfig struct {
MaxAttempts int
}
type generationTaskStateCheckConfig struct {
Interval time.Duration
Timeout time.Duration
BatchSize int
Lookback time.Duration
}
type claimedGenerationTaskLease struct {
task generationTaskRecoveryRecord
token string
@@ -139,6 +146,17 @@ func normalizeGenerationTaskRecoveryConfig(cfg config.GenerationConfig) generati
}
}
func normalizeGenerationTaskStateCheckConfig(cfg config.GenerationConfig) generationTaskStateCheckConfig {
normalized := cfg
config.NormalizeGenerationConfig(&normalized)
return generationTaskStateCheckConfig{
Interval: normalized.TaskStateCheckInterval,
Timeout: normalized.TaskStateCheckTimeout,
BatchSize: normalized.TaskStateCheckBatchSize,
Lookback: normalized.TaskStateCheckLookback,
}
}
func (w *ArticleGenerationWorker) claimGenerationTask(ctx context.Context, taskID int64, owner string, leaseTTL time.Duration) (*claimedGenerationTaskLease, bool, error) {
if leaseTTL <= 0 {
leaseTTL = normalizeGenerationTaskRecoveryConfig(config.GenerationConfig{}).LeaseTTL
@@ -362,6 +380,20 @@ func (w *ArticleGenerationWorker) requeueStaleGenerationTasks(ctx context.Contex
}
continue
}
logCtx := tenantapp.GenerationTaskLogContext{
TaskID: item.ID,
TenantID: item.TenantID,
ArticleID: item.ArticleID,
TaskType: item.TaskType,
Stage: "recovery_stale_queued",
StatusBefore: "queued",
StatusAfter: "queued",
Result: tenantapp.GenerationTaskResultSuccess,
Severity: "warning",
AttemptCount: item.AttemptCount,
}
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventRecoveryRequeue, logCtx)
tenantapp.LogGenerationTaskWarn(w.logger, "article generation stale queued task republished", logCtx)
requeued++
}
@@ -538,17 +570,39 @@ func (w *ArticleGenerationWorker) requeueExpiredRunningGenerationTask(ctx contex
return false, err
}
if !shouldRequeue {
logCtx := tenantapp.GenerationTaskLogContext{
TaskID: item.ID,
TenantID: tenantID,
ArticleID: articleID,
TaskType: taskType,
Stage: "recovery_expired_running",
StatusBefore: "running",
StatusAfter: "failed",
Result: tenantapp.GenerationTaskResultSkipped,
Severity: "warning",
}
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventRecoveryFinalize, logCtx)
if w.logger != nil {
w.logger.Info("article generation expired task finalized because article is already failed",
zap.Int64("task_id", item.ID),
zap.Int64("article_id", articleID),
)
tenantapp.LogGenerationTaskInfo(w.logger, "article generation expired task finalized because article is already failed", logCtx)
}
return true, nil
}
if err := w.publishGenerationTask(ctx, item.ID, taskType, tenantID, articleID); err != nil {
return false, err
}
logCtx := tenantapp.GenerationTaskLogContext{
TaskID: item.ID,
TenantID: tenantID,
ArticleID: articleID,
TaskType: taskType,
Stage: "recovery_expired_running",
StatusBefore: "running",
StatusAfter: "queued",
Result: tenantapp.GenerationTaskResultSuccess,
Severity: "warning",
}
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventRecoveryRequeue, logCtx)
tenantapp.LogGenerationTaskWarn(w.logger, "article generation expired task requeued", logCtx)
return true, nil
}
@@ -672,6 +726,21 @@ func (w *ArticleGenerationWorker) finalizeExpiredGenerationTask(ctx context.Cont
if item.ArticleID > 0 {
tenantapp.InvalidateArticleCaches(cacheCtx, w.cache, item.TenantID, &item.ArticleID)
}
logCtx := tenantapp.GenerationTaskLogContext{
TaskID: item.ID,
TenantID: item.TenantID,
OperatorID: item.OperatorID,
ArticleID: item.ArticleID,
ReservationID: item.QuotaReservationID,
TaskType: item.TaskType,
Stage: "recovery_finalize",
StatusAfter: "failed",
Result: tenantapp.GenerationTaskResultSuccess,
Severity: "critical",
AttemptCount: item.AttemptCount,
}
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventRecoveryFinalize, logCtx)
tenantapp.LogGenerationTaskWarn(w.logger, "article generation stale task finalized", logCtx)
return true, nil
}
@@ -31,6 +31,20 @@ func TestNormalizeGenerationTaskRecoveryConfigUsesGenerationDefaults(t *testing.
if cfg.MaxAttempts != 3 {
t.Fatalf("expected max attempts 3, got %d", cfg.MaxAttempts)
}
stateCheck := normalizeGenerationTaskStateCheckConfig(config.GenerationConfig{})
if stateCheck.Interval != 5*time.Minute {
t.Fatalf("expected state check interval 5m, got %s", stateCheck.Interval)
}
if stateCheck.Timeout != 10*time.Second {
t.Fatalf("expected state check timeout 10s, got %s", stateCheck.Timeout)
}
if stateCheck.BatchSize != 100 {
t.Fatalf("expected state check batch size 100, got %d", stateCheck.BatchSize)
}
if stateCheck.Lookback != 24*time.Hour {
t.Fatalf("expected state check lookback 24h, got %s", stateCheck.Lookback)
}
}
func TestNormalizeGenerationTaskRecoveryConfigKeepsOverrides(t *testing.T) {
@@ -52,6 +66,19 @@ func TestNormalizeGenerationTaskRecoveryConfigKeepsOverrides(t *testing.T) {
cfg.MaxAttempts != 5 {
t.Fatalf("unexpected recovery config: %#v", cfg)
}
stateCheck := normalizeGenerationTaskStateCheckConfig(config.GenerationConfig{
TaskStateCheckInterval: 9 * time.Minute,
TaskStateCheckTimeout: 11 * time.Second,
TaskStateCheckBatchSize: 13,
TaskStateCheckLookback: 2 * time.Hour,
})
if stateCheck.Interval != 9*time.Minute ||
stateCheck.Timeout != 11*time.Second ||
stateCheck.BatchSize != 13 ||
stateCheck.Lookback != 2*time.Hour {
t.Fatalf("unexpected state check config: %#v", stateCheck)
}
}
func TestGenerationWorkerOwnerIsBounded(t *testing.T) {
@@ -0,0 +1,191 @@
package generate
import (
"context"
"database/sql"
"time"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
)
const (
generationTaskAnomalyArticleFailedTaskActive = "article_failed_task_active"
generationTaskAnomalyArticleCompletedTaskActive = "article_completed_task_active"
generationTaskAnomalyCompletedTaskArticleMissing = "completed_task_article_not_completed"
generationTaskAnomalyCompletedTaskMissingVersion = "completed_task_missing_version"
generationTaskAnomalyExpiredRunningLease = "expired_running_lease"
)
type generationTaskStateAnomaly struct {
TaskID int64
TenantID int64
OperatorID int64
ArticleID int64
ReservationID int64
TaskType string
TaskStatus string
ArticleStatus string
AttemptCount int
LeaseOwner string
AnomalyType string
Severity string
}
func CheckGenerationTaskStateConsistency(ctx context.Context, pool generationTaskStateDB, logger *zap.Logger, cfg generationTaskStateCheckConfig) (int, error) {
if pool == nil || cfg.BatchSize <= 0 {
return 0, nil
}
if cfg.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
}
lookback := time.Now().UTC().Add(-cfg.Lookback)
rows, err := pool.Query(ctx, `
WITH candidates AS (
SELECT
gt.id,
gt.tenant_id,
gt.operator_id,
gt.article_id,
gt.quota_reservation_id,
gt.task_type,
gt.status AS task_status,
a.generate_status AS article_status,
gt.attempt_count,
gt.lease_owner,
CASE
WHEN a.generate_status = 'failed' AND gt.status IN ('queued', 'running') THEN 'article_failed_task_active'
WHEN a.generate_status = 'completed' AND gt.status IN ('queued', 'running') THEN 'article_completed_task_active'
WHEN gt.status = 'completed' AND COALESCE(a.generate_status, '') <> 'completed' THEN 'completed_task_article_not_completed'
WHEN gt.status = 'completed' AND (a.current_version_id IS NULL OR av.id IS NULL) THEN 'completed_task_missing_version'
WHEN gt.status = 'running' AND (gt.lease_expires_at IS NULL OR gt.lease_expires_at < NOW()) THEN 'expired_running_lease'
ELSE ''
END AS anomaly_type
FROM generation_tasks gt
LEFT JOIN articles a
ON a.id = gt.article_id
AND a.tenant_id = gt.tenant_id
AND a.deleted_at IS NULL
LEFT JOIN article_versions av
ON av.id = a.current_version_id
WHERE gt.article_id IS NOT NULL
AND gt.created_at >= $1
AND (
gt.status IN ('queued', 'running')
OR gt.updated_at >= $1
)
AND (
(a.generate_status IN ('failed', 'completed') AND gt.status IN ('queued', 'running'))
OR (gt.status = 'completed' AND COALESCE(a.generate_status, '') <> 'completed')
OR (gt.status = 'completed' AND (a.current_version_id IS NULL OR av.id IS NULL))
OR (gt.status = 'running' AND (gt.lease_expires_at IS NULL OR gt.lease_expires_at < NOW()))
)
ORDER BY gt.updated_at DESC, gt.id DESC
LIMIT $2
)
SELECT id, tenant_id, operator_id, article_id, quota_reservation_id, task_type, task_status, article_status, attempt_count, lease_owner, anomaly_type
FROM candidates
WHERE anomaly_type <> ''
`, lookback, cfg.BatchSize)
if err != nil {
return 0, err
}
defer rows.Close()
count := 0
for rows.Next() {
item, err := scanGenerationTaskStateAnomaly(rows)
if err != nil {
return count, err
}
if item.AnomalyType == "" {
continue
}
count++
logCtx := tenantapp.GenerationTaskLogContext{
TaskID: item.TaskID,
TenantID: item.TenantID,
OperatorID: item.OperatorID,
ArticleID: item.ArticleID,
ReservationID: item.ReservationID,
TaskType: item.TaskType,
Stage: "state_check",
AttemptCount: item.AttemptCount,
LeaseOwner: item.LeaseOwner,
StatusBefore: item.TaskStatus,
StatusAfter: item.ArticleStatus,
Result: tenantapp.GenerationTaskResultFailure,
AnomalyType: item.AnomalyType,
Severity: item.Severity,
}
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventStateAnomaly, logCtx)
tenantapp.LogGenerationTaskError(logger, "article generation state consistency anomaly detected", logCtx,
zap.String("task_status", item.TaskStatus),
zap.String("article_status", item.ArticleStatus),
)
}
if err := rows.Err(); err != nil {
return count, err
}
return count, nil
}
type generationTaskStateAnomalyRow interface {
Scan(dest ...any) error
}
type generationTaskStateDB interface {
Query(context.Context, string, ...any) (pgx.Rows, error)
}
func scanGenerationTaskStateAnomaly(row generationTaskStateAnomalyRow) (generationTaskStateAnomaly, error) {
var (
item generationTaskStateAnomaly
operatorID sql.NullInt64
articleID sql.NullInt64
reservationID sql.NullInt64
articleStatus sql.NullString
leaseOwner sql.NullString
)
if err := row.Scan(
&item.TaskID,
&item.TenantID,
&operatorID,
&articleID,
&reservationID,
&item.TaskType,
&item.TaskStatus,
&articleStatus,
&item.AttemptCount,
&leaseOwner,
&item.AnomalyType,
); err != nil {
return generationTaskStateAnomaly{}, err
}
item.OperatorID = operatorID.Int64
item.ArticleID = articleID.Int64
item.ReservationID = reservationID.Int64
item.ArticleStatus = articleStatus.String
item.LeaseOwner = leaseOwner.String
item.Severity = generationTaskAnomalySeverity(item.AnomalyType)
return item, nil
}
func generationTaskAnomalySeverity(anomalyType string) string {
switch anomalyType {
case generationTaskAnomalyCompletedTaskArticleMissing,
generationTaskAnomalyCompletedTaskMissingVersion,
generationTaskAnomalyArticleFailedTaskActive:
return "critical"
case generationTaskAnomalyArticleCompletedTaskActive,
generationTaskAnomalyExpiredRunningLease:
return "warning"
default:
return "warning"
}
}
@@ -0,0 +1,67 @@
package generate
import (
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type generationTaskStateFakeRow struct {
values []any
}
func (r generationTaskStateFakeRow) Scan(dest ...any) error {
for idx := range dest {
switch target := dest[idx].(type) {
case *int64:
*target = r.values[idx].(int64)
case *int:
*target = r.values[idx].(int)
case *string:
*target = r.values[idx].(string)
case *sql.NullInt64:
*target = r.values[idx].(sql.NullInt64)
case *sql.NullString:
*target = r.values[idx].(sql.NullString)
}
}
return nil
}
func TestScanGenerationTaskStateAnomalyNormalizesNullableFields(t *testing.T) {
item, err := scanGenerationTaskStateAnomaly(generationTaskStateFakeRow{values: []any{
int64(11),
int64(22),
sql.NullInt64{Int64: 33, Valid: true},
sql.NullInt64{Int64: 44, Valid: true},
sql.NullInt64{Int64: 55, Valid: true},
"template",
"completed",
sql.NullString{String: "generating", Valid: true},
2,
sql.NullString{String: "worker-1", Valid: true},
generationTaskAnomalyCompletedTaskArticleMissing,
}})
require.NoError(t, err)
assert.Equal(t, int64(11), item.TaskID)
assert.Equal(t, int64(22), item.TenantID)
assert.Equal(t, int64(33), item.OperatorID)
assert.Equal(t, int64(44), item.ArticleID)
assert.Equal(t, int64(55), item.ReservationID)
assert.Equal(t, "template", item.TaskType)
assert.Equal(t, "completed", item.TaskStatus)
assert.Equal(t, "generating", item.ArticleStatus)
assert.Equal(t, 2, item.AttemptCount)
assert.Equal(t, "worker-1", item.LeaseOwner)
assert.Equal(t, "critical", item.Severity)
}
func TestGenerationTaskAnomalySeverity(t *testing.T) {
assert.Equal(t, "critical", generationTaskAnomalySeverity(generationTaskAnomalyCompletedTaskMissingVersion))
assert.Equal(t, "critical", generationTaskAnomalySeverity(generationTaskAnomalyArticleFailedTaskActive))
assert.Equal(t, "warning", generationTaskAnomalySeverity(generationTaskAnomalyExpiredRunningLease))
assert.Equal(t, "warning", generationTaskAnomalySeverity("unknown"))
}
@@ -0,0 +1,98 @@
package generate
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/config"
tenantapp "github.com/geo-platform/tenant-api/internal/tenant/app"
)
type GenerationTaskStateCheckWorker struct {
pool *pgxpool.Pool
logger *zap.Logger
cfg config.GenerationConfig
provider config.Provider
}
func NewGenerationTaskStateCheckWorker(pool *pgxpool.Pool, logger *zap.Logger, cfg config.GenerationConfig) *GenerationTaskStateCheckWorker {
return &GenerationTaskStateCheckWorker{
pool: pool,
logger: logger,
cfg: cfg,
}
}
func (w *GenerationTaskStateCheckWorker) WithConfigProvider(provider config.Provider) *GenerationTaskStateCheckWorker {
w.provider = provider
return w
}
func (w *GenerationTaskStateCheckWorker) Start(ctx context.Context) {
go w.Run(ctx)
}
func (w *GenerationTaskStateCheckWorker) Run(ctx context.Context) {
if w == nil || w.pool == nil {
return
}
w.run(ctx)
}
func (w *GenerationTaskStateCheckWorker) run(ctx context.Context) {
w.runOnce(context.Background())
for {
cfg := w.runtimeConfig()
timer := time.NewTimer(cfg.Interval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
w.runOnce(context.Background())
}
}
}
func (w *GenerationTaskStateCheckWorker) runOnce(parent context.Context) {
cfg := w.runtimeConfig()
startedAt := time.Now()
count, err := CheckGenerationTaskStateConsistency(parent, w.pool, w.logger, cfg)
duration := time.Since(startedAt)
if err != nil {
logCtx := tenantapp.GenerationTaskLogContext{
Stage: "state_check",
Result: tenantapp.GenerationTaskResultFailure,
Severity: "warning",
Duration: duration,
}
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventStateCheckFailure, logCtx)
tenantapp.LogGenerationTaskWarn(w.logger, "article generation state consistency check failed", logCtx, zap.Error(err))
return
}
if count == 0 {
return
}
tenantapp.LogGenerationTaskWarn(w.logger, "article generation state consistency anomalies detected", tenantapp.GenerationTaskLogContext{
Stage: "state_check",
Result: tenantapp.GenerationTaskResultSuccess,
Severity: "critical",
Duration: duration,
}, zap.Int("anomaly_count", count))
}
func (w *GenerationTaskStateCheckWorker) runtimeConfig() generationTaskStateCheckConfig {
if w != nil && w.provider != nil {
if cfg := w.provider.Current(); cfg != nil {
return normalizeGenerationTaskStateCheckConfig(cfg.Generation)
}
}
if w != nil {
return normalizeGenerationTaskStateCheckConfig(w.cfg)
}
return normalizeGenerationTaskStateCheckConfig(config.GenerationConfig{})
}
@@ -180,6 +180,10 @@ func (w *ArticleGenerationWorker) handleDelivery(parent context.Context, deliver
}
task := lease.task.toClaimedTask()
owner := generationWorkerOwner(delivery.ConsumerTag)
claimLogCtx := generationWorkerTaskLogContext(task, "worker_claim", "queued", tenantapp.GenerationTaskStatusRunning, tenantapp.GenerationTaskResultSuccess, lease.task.AttemptCount, owner)
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventTransition, claimLogCtx)
tenantapp.LogGenerationTaskInfo(w.logger, "article generation task claimed", claimLogCtx)
heartbeatCtx, stopHeartbeat := context.WithCancel(parent)
go w.runGenerationLeaseHeartbeat(heartbeatCtx, task.ID, lease.token, recoveryCfg.LeaseTTL)
defer func() {
@@ -245,6 +249,9 @@ func articleGenerationProcessTimeout(cfg config.GenerationConfig) time.Duration
func (w *ArticleGenerationWorker) handleTaskFailure(ctx context.Context, task tenantapp.ClaimedGenerationTask, stage string, taskErr error) {
task.TaskType = strings.TrimSpace(task.TaskType)
logCtx := generationWorkerTaskLogContext(task, stage, "", "failed", tenantapp.GenerationTaskResultFailure, 0, "")
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventFailure, logCtx)
tenantapp.LogGenerationTaskWarn(w.logger, "article generation task failed", logCtx, zap.Error(taskErr))
var err error
switch {
case task.TaskType == kolGenerationTaskType && w.kolWorker != nil:
@@ -255,13 +262,8 @@ func (w *ArticleGenerationWorker) handleTaskFailure(ctx context.Context, task te
err = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, taskErr)
}
if err != nil && w.logger != nil {
w.logger.Warn("article generation task failure handling failed",
zap.Error(err),
zap.Int64("task_id", task.ID),
zap.Int64("article_id", task.ArticleID),
zap.String("task_type", task.TaskType),
zap.String("stage", stage),
)
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventCleanupFailure, logCtx)
tenantapp.LogGenerationTaskError(w.logger, "article generation task failure handling failed", logCtx, zap.Error(err))
}
}
@@ -280,15 +282,39 @@ func (w *ArticleGenerationWorker) rejectDelivery(delivery amqp.Delivery, requeue
)
}
if w.logger != nil {
w.logger.Warn("article generation processing failed",
logCtx := tenantapp.GenerationTaskLogContext{
TaskID: taskID,
Stage: "delivery_reject",
Result: tenantapp.GenerationTaskResultFailure,
Severity: "warning",
StatusAfter: "rejected",
}
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventQueueFailure, logCtx)
tenantapp.LogGenerationTaskWarn(w.logger, "article generation processing failed", logCtx,
zap.Error(err),
zap.Bool("requeue", requeue),
zap.Bool("redelivered", delivery.Redelivered),
zap.Int64("task_id", taskID),
)
}
}
func generationWorkerTaskLogContext(task tenantapp.ClaimedGenerationTask, stage, statusBefore, statusAfter, result string, attemptCount int, leaseOwner string) tenantapp.GenerationTaskLogContext {
return tenantapp.GenerationTaskLogContext{
TaskID: task.ID,
TenantID: task.TenantID,
OperatorID: task.OperatorID,
ArticleID: task.ArticleID,
ReservationID: task.QuotaReservationID,
TaskType: task.TaskType,
Stage: stage,
AttemptCount: attemptCount,
LeaseOwner: leaseOwner,
StatusBefore: statusBefore,
StatusAfter: statusAfter,
Result: result,
}
}
func parseJSONMap(raw []byte) (map[string]interface{}, error) {
if len(raw) == 0 {
return map[string]interface{}{}, nil