fix(generation): repair articles stuck in generating when latest task is terminal
The state-consistency worker now detects articles still flagged as generating whose latest generation_task is already failed or completed (with a version) and aligns the article status to the terminal task, invalidating article caches on the way out. Adds supporting partial indexes and switches UpdateGenerationTaskStatus to COALESCE started_at and completed_at so partial status updates don't wipe the timestamps.
This commit is contained in:
@@ -93,7 +93,8 @@ func main() {
|
|||||||
monitoringReceivedInspectionWorker := internalscheduler.NewMonitoringReceivedInspectionWorker(app.MonitoringDB, app.Logger)
|
monitoringReceivedInspectionWorker := internalscheduler.NewMonitoringReceivedInspectionWorker(app.MonitoringDB, app.Logger)
|
||||||
knowledgeDeletedCleanupWorker := internalscheduler.NewKnowledgeDeletedCleanupWorker(knowledgeSvc)
|
knowledgeDeletedCleanupWorker := internalscheduler.NewKnowledgeDeletedCleanupWorker(knowledgeSvc)
|
||||||
generationStateCheckWorker := generateworker.NewGenerationTaskStateCheckWorker(app.DB, app.Logger, app.Config().Generation).
|
generationStateCheckWorker := generateworker.NewGenerationTaskStateCheckWorker(app.DB, app.Logger, app.Config().Generation).
|
||||||
WithConfigProvider(app.ConfigStore)
|
WithConfigProvider(app.ConfigStore).
|
||||||
|
WithCache(app.Cache)
|
||||||
|
|
||||||
startSchedulerWorker(ctx, &workerWG, "schedule_dispatch", app.Logger.Sugar(), scheduleDispatchWorker.Run)
|
startSchedulerWorker(ctx, &workerWG, "schedule_dispatch", app.Logger.Sugar(), scheduleDispatchWorker.Run)
|
||||||
startSchedulerWorker(ctx, &workerWG, "monitoring_daily_task", app.Logger.Sugar(), monitoringDailyTaskWorker.Run)
|
startSchedulerWorker(ctx, &workerWG, "monitoring_daily_task", app.Logger.Sugar(), monitoringDailyTaskWorker.Run)
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ func (q *Queries) CreateTaskRecord(ctx context.Context, arg CreateTaskRecordPara
|
|||||||
|
|
||||||
const updateGenerationTaskStatus = `-- name: UpdateGenerationTaskStatus :exec
|
const updateGenerationTaskStatus = `-- name: UpdateGenerationTaskStatus :exec
|
||||||
UPDATE generation_tasks SET status = $1::text, error_message = $2,
|
UPDATE generation_tasks SET status = $1::text, error_message = $2,
|
||||||
started_at = $3,
|
started_at = COALESCE($3, started_at),
|
||||||
completed_at = $4,
|
completed_at = COALESCE($4, completed_at),
|
||||||
lease_token = CASE WHEN $1::text IN ('completed', 'failed') THEN NULL ELSE lease_token END,
|
lease_token = CASE WHEN $1::text IN ('completed', 'failed') THEN NULL ELSE lease_token END,
|
||||||
lease_owner = CASE WHEN $1::text IN ('completed', 'failed') THEN NULL ELSE lease_owner END,
|
lease_owner = CASE WHEN $1::text IN ('completed', 'failed') THEN NULL ELSE lease_owner END,
|
||||||
lease_expires_at = CASE WHEN $1::text IN ('completed', 'failed') THEN NULL ELSE lease_expires_at END,
|
lease_expires_at = CASE WHEN $1::text IN ('completed', 'failed') THEN NULL ELSE lease_expires_at END,
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ WHERE tenant_id = sqlc.arg(tenant_id)
|
|||||||
|
|
||||||
-- name: UpdateGenerationTaskStatus :exec
|
-- name: UpdateGenerationTaskStatus :exec
|
||||||
UPDATE generation_tasks SET status = sqlc.arg(status)::text, error_message = sqlc.arg(error_message),
|
UPDATE generation_tasks SET status = sqlc.arg(status)::text, error_message = sqlc.arg(error_message),
|
||||||
started_at = sqlc.arg(started_at),
|
started_at = COALESCE(sqlc.arg(started_at), started_at),
|
||||||
completed_at = sqlc.arg(completed_at),
|
completed_at = COALESCE(sqlc.arg(completed_at), completed_at),
|
||||||
lease_token = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed') THEN NULL ELSE lease_token END,
|
lease_token = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed') THEN NULL ELSE lease_token END,
|
||||||
lease_owner = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed') THEN NULL ELSE lease_owner END,
|
lease_owner = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed') THEN NULL ELSE lease_owner END,
|
||||||
lease_expires_at = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed') THEN NULL ELSE lease_expires_at END,
|
lease_expires_at = CASE WHEN sqlc.arg(status)::text IN ('completed', 'failed') THEN NULL ELSE lease_expires_at END,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ const (
|
|||||||
generationTaskAnomalyCompletedTaskArticleMissing = "completed_task_article_not_completed"
|
generationTaskAnomalyCompletedTaskArticleMissing = "completed_task_article_not_completed"
|
||||||
generationTaskAnomalyCompletedTaskMissingVersion = "completed_task_missing_version"
|
generationTaskAnomalyCompletedTaskMissingVersion = "completed_task_missing_version"
|
||||||
generationTaskAnomalyExpiredRunningLease = "expired_running_lease"
|
generationTaskAnomalyExpiredRunningLease = "expired_running_lease"
|
||||||
|
generationTaskAnomalyTerminalTaskArticleActive = "terminal_task_article_generating"
|
||||||
)
|
)
|
||||||
|
|
||||||
type generationTaskStateAnomaly struct {
|
type generationTaskStateAnomaly struct {
|
||||||
@@ -34,7 +36,7 @@ type generationTaskStateAnomaly struct {
|
|||||||
Severity string
|
Severity string
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckGenerationTaskStateConsistency(ctx context.Context, pool generationTaskStateDB, logger *zap.Logger, cfg generationTaskStateCheckConfig) (int, error) {
|
func CheckGenerationTaskStateConsistency(ctx context.Context, pool generationTaskStateDB, logger *zap.Logger, cache sharedcache.Cache, cfg generationTaskStateCheckConfig) (int, error) {
|
||||||
if pool == nil || cfg.BatchSize <= 0 {
|
if pool == nil || cfg.BatchSize <= 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@@ -132,6 +134,150 @@ func CheckGenerationTaskStateConsistency(ctx context.Context, pool generationTas
|
|||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
repaired, err := repairGenerationTaskArticleState(ctx, pool, logger, cache, cfg, lookback)
|
||||||
|
if err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
count += repaired
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func repairGenerationTaskArticleState(ctx context.Context, pool generationTaskStateDB, logger *zap.Logger, cache sharedcache.Cache, cfg generationTaskStateCheckConfig, lookback time.Time) (int, error) {
|
||||||
|
if pool == nil || cfg.BatchSize <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
WITH recent_terminal 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,
|
||||||
|
gt.error_message,
|
||||||
|
gt.attempt_count,
|
||||||
|
gt.lease_owner,
|
||||||
|
gt.created_at,
|
||||||
|
gt.updated_at
|
||||||
|
FROM generation_tasks gt
|
||||||
|
WHERE gt.article_id IS NOT NULL
|
||||||
|
AND gt.status IN ('failed', 'completed')
|
||||||
|
AND gt.updated_at >= $1
|
||||||
|
ORDER BY gt.updated_at DESC, gt.id DESC
|
||||||
|
LIMIT ($2::int * 5)
|
||||||
|
),
|
||||||
|
latest_terminal AS (
|
||||||
|
SELECT DISTINCT ON (rt.article_id)
|
||||||
|
rt.id,
|
||||||
|
rt.tenant_id,
|
||||||
|
rt.operator_id,
|
||||||
|
rt.article_id,
|
||||||
|
rt.quota_reservation_id,
|
||||||
|
rt.task_type,
|
||||||
|
rt.task_status,
|
||||||
|
rt.error_message,
|
||||||
|
rt.attempt_count,
|
||||||
|
rt.lease_owner,
|
||||||
|
rt.created_at,
|
||||||
|
rt.updated_at
|
||||||
|
FROM recent_terminal rt
|
||||||
|
ORDER BY rt.article_id, rt.created_at DESC, rt.id DESC
|
||||||
|
),
|
||||||
|
candidates AS (
|
||||||
|
SELECT
|
||||||
|
lt.id,
|
||||||
|
lt.tenant_id,
|
||||||
|
lt.operator_id,
|
||||||
|
lt.article_id,
|
||||||
|
lt.quota_reservation_id,
|
||||||
|
lt.task_type,
|
||||||
|
lt.task_status,
|
||||||
|
a.generate_status AS article_status,
|
||||||
|
lt.attempt_count,
|
||||||
|
lt.lease_owner
|
||||||
|
FROM latest_terminal lt
|
||||||
|
JOIN articles a
|
||||||
|
ON a.id = lt.article_id
|
||||||
|
AND a.tenant_id = lt.tenant_id
|
||||||
|
AND a.deleted_at IS NULL
|
||||||
|
WHERE a.generate_status = 'generating'
|
||||||
|
AND (
|
||||||
|
lt.task_status = 'failed'
|
||||||
|
OR (lt.task_status = 'completed' AND a.current_version_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM generation_tasks newer
|
||||||
|
WHERE newer.article_id = lt.article_id
|
||||||
|
AND newer.tenant_id = lt.tenant_id
|
||||||
|
AND (newer.created_at > lt.created_at OR (newer.created_at = lt.created_at AND newer.id > lt.id))
|
||||||
|
)
|
||||||
|
ORDER BY lt.updated_at DESC, lt.id DESC
|
||||||
|
LIMIT $2
|
||||||
|
),
|
||||||
|
updated AS (
|
||||||
|
UPDATE articles a
|
||||||
|
SET generate_status = c.task_status,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM candidates c
|
||||||
|
WHERE a.id = c.article_id
|
||||||
|
AND a.tenant_id = c.tenant_id
|
||||||
|
AND a.generate_status = 'generating'
|
||||||
|
RETURNING
|
||||||
|
c.id,
|
||||||
|
c.tenant_id,
|
||||||
|
c.operator_id,
|
||||||
|
c.article_id,
|
||||||
|
c.quota_reservation_id,
|
||||||
|
c.task_type,
|
||||||
|
c.task_status,
|
||||||
|
c.article_status,
|
||||||
|
c.attempt_count,
|
||||||
|
c.lease_owner,
|
||||||
|
'terminal_task_article_generating'::text AS anomaly_type
|
||||||
|
)
|
||||||
|
SELECT id, tenant_id, operator_id, article_id, quota_reservation_id, task_type, task_status, article_status, attempt_count, lease_owner, anomaly_type
|
||||||
|
FROM updated
|
||||||
|
`, 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
|
||||||
|
}
|
||||||
|
item.AnomalyType = generationTaskAnomalyTerminalTaskArticleActive
|
||||||
|
item.Severity = generationTaskAnomalySeverity(item.AnomalyType)
|
||||||
|
count++
|
||||||
|
logCtx := tenantapp.GenerationTaskLogContext{
|
||||||
|
TaskID: item.TaskID,
|
||||||
|
TenantID: item.TenantID,
|
||||||
|
OperatorID: item.OperatorID,
|
||||||
|
ArticleID: item.ArticleID,
|
||||||
|
ReservationID: item.ReservationID,
|
||||||
|
TaskType: item.TaskType,
|
||||||
|
Stage: "state_repair",
|
||||||
|
AttemptCount: item.AttemptCount,
|
||||||
|
LeaseOwner: item.LeaseOwner,
|
||||||
|
StatusBefore: item.ArticleStatus,
|
||||||
|
StatusAfter: item.TaskStatus,
|
||||||
|
Result: tenantapp.GenerationTaskResultSuccess,
|
||||||
|
AnomalyType: item.AnomalyType,
|
||||||
|
Severity: item.Severity,
|
||||||
|
}
|
||||||
|
tenantapp.RecordGenerationTaskEvent(tenantapp.GenerationTaskEventStateAnomaly, logCtx)
|
||||||
|
tenantapp.LogGenerationTaskWarn(logger, "article generation state repaired from latest terminal task", logCtx)
|
||||||
|
stateRepairCacheInvalidator(ctx, cache, item.TenantID, &item.ArticleID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +289,10 @@ type generationTaskStateDB interface {
|
|||||||
Query(context.Context, string, ...any) (pgx.Rows, error)
|
Query(context.Context, string, ...any) (pgx.Rows, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stateRepairCacheInvalidator = func(ctx context.Context, c sharedcache.Cache, tenantID int64, articleID *int64) {
|
||||||
|
tenantapp.InvalidateArticleCaches(ctx, c, tenantID, articleID)
|
||||||
|
}
|
||||||
|
|
||||||
func scanGenerationTaskStateAnomaly(row generationTaskStateAnomalyRow) (generationTaskStateAnomaly, error) {
|
func scanGenerationTaskStateAnomaly(row generationTaskStateAnomalyRow) (generationTaskStateAnomaly, error) {
|
||||||
var (
|
var (
|
||||||
item generationTaskStateAnomaly
|
item generationTaskStateAnomaly
|
||||||
@@ -180,7 +330,8 @@ func generationTaskAnomalySeverity(anomalyType string) string {
|
|||||||
switch anomalyType {
|
switch anomalyType {
|
||||||
case generationTaskAnomalyCompletedTaskArticleMissing,
|
case generationTaskAnomalyCompletedTaskArticleMissing,
|
||||||
generationTaskAnomalyCompletedTaskMissingVersion,
|
generationTaskAnomalyCompletedTaskMissingVersion,
|
||||||
generationTaskAnomalyArticleFailedTaskActive:
|
generationTaskAnomalyArticleFailedTaskActive,
|
||||||
|
generationTaskAnomalyTerminalTaskArticleActive:
|
||||||
return "critical"
|
return "critical"
|
||||||
case generationTaskAnomalyArticleCompletedTaskActive,
|
case generationTaskAnomalyArticleCompletedTaskActive,
|
||||||
generationTaskAnomalyExpiredRunningLease:
|
generationTaskAnomalyExpiredRunningLease:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ type GenerationTaskStateCheckWorker struct {
|
|||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
cfg config.GenerationConfig
|
cfg config.GenerationConfig
|
||||||
provider config.Provider
|
provider config.Provider
|
||||||
|
cache sharedcache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGenerationTaskStateCheckWorker(pool *pgxpool.Pool, logger *zap.Logger, cfg config.GenerationConfig) *GenerationTaskStateCheckWorker {
|
func NewGenerationTaskStateCheckWorker(pool *pgxpool.Pool, logger *zap.Logger, cfg config.GenerationConfig) *GenerationTaskStateCheckWorker {
|
||||||
@@ -31,6 +33,11 @@ func (w *GenerationTaskStateCheckWorker) WithConfigProvider(provider config.Prov
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *GenerationTaskStateCheckWorker) WithCache(cache sharedcache.Cache) *GenerationTaskStateCheckWorker {
|
||||||
|
w.cache = cache
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
func (w *GenerationTaskStateCheckWorker) Start(ctx context.Context) {
|
func (w *GenerationTaskStateCheckWorker) Start(ctx context.Context) {
|
||||||
go w.Run(ctx)
|
go w.Run(ctx)
|
||||||
}
|
}
|
||||||
@@ -61,7 +68,7 @@ func (w *GenerationTaskStateCheckWorker) run(ctx context.Context) {
|
|||||||
func (w *GenerationTaskStateCheckWorker) runOnce(parent context.Context) {
|
func (w *GenerationTaskStateCheckWorker) runOnce(parent context.Context) {
|
||||||
cfg := w.runtimeConfig()
|
cfg := w.runtimeConfig()
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
count, err := CheckGenerationTaskStateConsistency(parent, w.pool, w.logger, cfg)
|
count, err := CheckGenerationTaskStateConsistency(parent, w.pool, w.logger, w.cache, cfg)
|
||||||
duration := time.Since(startedAt)
|
duration := time.Since(startedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logCtx := tenantapp.GenerationTaskLogContext{
|
logCtx := tenantapp.GenerationTaskLogContext{
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_articles_generating_state_repair;
|
||||||
|
DROP INDEX IF EXISTS idx_generation_tasks_article_latest_state_repair;
|
||||||
|
DROP INDEX IF EXISTS idx_generation_tasks_recent_terminal_state_repair;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS idx_generation_tasks_recent_terminal_state_repair
|
||||||
|
ON generation_tasks(updated_at DESC, id DESC)
|
||||||
|
WHERE article_id IS NOT NULL
|
||||||
|
AND status IN ('failed', 'completed');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_generation_tasks_article_latest_state_repair
|
||||||
|
ON generation_tasks(article_id, tenant_id, created_at DESC, id DESC)
|
||||||
|
WHERE article_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_articles_generating_state_repair
|
||||||
|
ON articles(id, tenant_id)
|
||||||
|
WHERE generate_status = 'generating'
|
||||||
|
AND deleted_at IS NULL;
|
||||||
Reference in New Issue
Block a user