Files
xinmi/scripts/native_graph_backfill.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

836 lines
38 KiB
Go

// Run explicitly by filename, like scripts/wiki_backfill_trigger.go.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"net"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/Tencent/WeKnora/internal/common"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
"github.com/hibiken/asynq"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
type graphRecoveryCandidate struct {
ID string `json:"chunk_id"`
KnowledgeID string `json:"knowledge_id"`
SeqID int64 `json:"seq_id"`
ChunkIndex int `json:"chunk_index"`
ContentHash string `json:"content_sha256"`
}
// Failure records survive Redis retention and watcher restarts. Raw provider
// errors are deliberately excluded: state and stable identities are sufficient
// to find the task's private diagnostics and authorize a bounded manual retry.
type graphRecoveryFailure struct {
TaskID string `gorm:"primaryKey" json:"task_id"`
TenantID uint64 `gorm:"index:idx_graph_backfill_scope;index:idx_graph_backfill_candidate,priority:1" json:"tenant_id"`
KnowledgeBaseID string `gorm:"index:idx_graph_backfill_scope;index:idx_graph_backfill_candidate,priority:2" json:"knowledge_base_id"`
ChunkID string `gorm:"index:idx_graph_backfill_candidate,priority:3" json:"chunk_id"`
KnowledgeID string `json:"knowledge_id"`
ContentHash string `gorm:"index:idx_graph_backfill_candidate,priority:4" json:"content_sha256"`
Revision string `gorm:"index:idx_graph_backfill_candidate,priority:5" json:"revision"`
State string `gorm:"index:idx_graph_backfill_candidate,priority:6" json:"state"`
Reason string `json:"reason"`
ManualRetries int `json:"manual_retries"`
RetryReason string `json:"retry_reason,omitempty"`
FirstFailedAt time.Time `json:"first_failed_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (graphRecoveryFailure) TableName() string { return "native_graph_backfill_failures" }
func graphFailureFilter(includeFailed bool) string {
if includeFailed {
return ""
}
return ` AND NOT EXISTS(SELECT 1 FROM native_graph_backfill_failures f WHERE f.tenant_id=c.tenant_id AND f.knowledge_base_id=c.knowledge_base_id AND f.chunk_id=c.id AND f.content_hash=encode(sha256(convert_to(c.content,'UTF8')),'hex') AND f.revision=? AND f.state='failed')`
}
const graphCurrentFailureSource = `EXISTS(SELECT 1 FROM chunks c JOIN knowledges k ON k.id=c.knowledge_id AND k.tenant_id=c.tenant_id AND k.knowledge_base_id=c.knowledge_base_id AND k.deleted_at IS NULL WHERE c.tenant_id=f.tenant_id AND c.knowledge_base_id=f.knowledge_base_id AND c.id=f.chunk_id AND c.knowledge_id=f.knowledge_id AND c.deleted_at IS NULL AND c.is_enabled AND c.chunk_type='text' AND encode(sha256(convert_to(c.content,'UTF8')),'hex')=f.content_hash)`
const graphFailureCheckpoint = `EXISTS(SELECT 1 FROM native_graph_records r WHERE r.tenant_id=f.tenant_id AND r.knowledge_base_id=f.knowledge_base_id AND r.source_table='knowledgegraphchunkstate' AND r.payload->>'origin'='native_extraction_v1' AND r.payload->>'extraction_status'='complete' AND CAST(COALESCE(r.payload->>'relation_count','0') AS INTEGER)>0 AND r.payload->>'native_chunk_id'=f.chunk_id AND r.payload->>'native_knowledge_id'=f.knowledge_id AND r.payload->>'source_content_hash'=f.content_hash)`
func graphUnresolvedFailures(db *gorm.DB, tenant uint64, kbID, revision string, reconcile bool) ([]graphRecoveryFailure, error) {
if reconcile {
// Keep historical rows, but a deleted/changed source or a newly verified
// checkpoint must not leave a completed knowledge base blocked forever.
for _, resolution := range []struct{ state, condition string }{
{"superseded", "NOT " + graphCurrentFailureSource},
{"resolved", graphCurrentFailureSource + " AND " + graphFailureCheckpoint},
} {
if err := db.Exec("UPDATE native_graph_backfill_failures AS f SET state=?,updated_at=? WHERE tenant_id=? AND knowledge_base_id=? AND revision=? AND state NOT IN ('resolved','superseded') AND "+resolution.condition, resolution.state, time.Now().UTC(), tenant, kbID, revision).Error; err != nil {
return nil, err
}
}
}
var failures []graphRecoveryFailure
err := db.Table("native_graph_backfill_failures AS f").Where("f.tenant_id=? AND f.knowledge_base_id=? AND f.revision=? AND f.state='failed'", tenant, kbID, revision).Where(graphCurrentFailureSource).Where("NOT " + graphFailureCheckpoint).Order("f.first_failed_at,f.task_id").Find(&failures).Error
return failures, err
}
func graphRecoveryCandidates(db *gorm.DB, tenant uint64, kbID, revision string, after int64, limit int, hasFailures, includeFailed bool, exact ...graphRecoveryCandidate) ([]graphRecoveryCandidate, error) {
var candidates []graphRecoveryCandidate
filter := ""
queryArgs := []any{tenant, kbID, tenant, kbID, after}
if hasFailures && !includeFailed {
filter = graphFailureFilter(false)
queryArgs = append(queryArgs, revision)
}
if len(exact) > 0 {
filter += " AND c.id=? AND c.knowledge_id=? AND encode(sha256(convert_to(c.content,'UTF8')),'hex')=?"
queryArgs = append(queryArgs, exact[0].ID, exact[0].KnowledgeID, exact[0].ContentHash)
}
queryArgs = append(queryArgs, limit)
err := db.Raw(`WITH completed AS NOT MATERIALIZED(SELECT payload FROM native_graph_records WHERE tenant_id=? AND knowledge_base_id=? AND source_table='knowledgegraphchunkstate' AND payload->>'origin'='native_extraction_v1' AND payload->>'extraction_status'='complete' AND CAST(COALESCE(payload->>'relation_count','0') AS INTEGER)>0)
SELECT c.id,c.knowledge_id,c.seq_id,c.chunk_index,encode(sha256(convert_to(c.content,'UTF8')),'hex') AS content_hash
FROM chunks c JOIN knowledges k ON k.id=c.knowledge_id AND k.tenant_id=c.tenant_id AND k.knowledge_base_id=c.knowledge_base_id AND k.deleted_at IS NULL
WHERE c.tenant_id=? AND c.knowledge_base_id=? AND c.deleted_at IS NULL AND c.is_enabled AND c.chunk_type='text' AND c.seq_id>? AND k.parse_status='completed'
AND NOT EXISTS(SELECT 1 FROM completed r WHERE r.payload->>'native_chunk_id'=c.id AND r.payload->>'native_knowledge_id'=c.knowledge_id AND r.payload->>'source_content_hash'=encode(sha256(convert_to(c.content,'UTF8')),'hex'))
`+filter+` ORDER BY c.seq_id LIMIT ?`, queryArgs...).Scan(&candidates).Error
if err == nil && len(exact) > 0 && len(candidates) != 1 {
return nil, errors.New("exact graph source unavailable, changed, completed or excluded by its failure ledger; no substitute was selected")
}
return candidates, err
}
func graphValidateExactScope(chunkID, knowledgeID, hash string, after int64, limit, maxOutstanding int) error {
if chunkID == "" && knowledgeID == "" && hash == "" {
return nil
}
chunk, chunkErr := uuid.Parse(chunkID)
knowledge, knowledgeErr := uuid.Parse(knowledgeID)
decoded, hashErr := hex.DecodeString(hash)
if chunkErr != nil || knowledgeErr != nil || chunk == uuid.Nil || knowledge == uuid.Nil ||
chunk.String() != chunkID || knowledge.String() != knowledgeID ||
hashErr != nil || len(decoded) != sha256.Size || hex.EncodeToString(decoded) != hash ||
after != 0 || limit != 1 || maxOutstanding != 1 {
return errors.New("exact graph scope requires canonical chunk and knowledge UUIDs, lowercase source SHA-256, limit=1, max-outstanding=1 and no cursor")
}
return nil
}
func graphTaskMatchesReviewedSource(payload []byte, tenant uint64, kbID string, c graphRecoveryCandidate) bool {
var task types.ExtractChunkPayload
return json.Unmarshal(payload, &task) == nil && task.TenantID == tenant && task.ChunkID == c.ID &&
(task.KnowledgeID == "" || task.KnowledgeID == c.KnowledgeID) &&
task.ExpectedSource != nil && task.ExpectedSource.KnowledgeBaseID == kbID &&
task.ExpectedSource.KnowledgeID == c.KnowledgeID && task.ExpectedSource.ContentSHA256 == c.ContentHash
}
type graphRecoveryQueue interface {
RunTask(string, string) error
}
func graphQueueSlots(info *asynq.QueueInfo, limit int) int {
if info == nil || info.Paused {
return 0
}
remaining := limit - info.Active - info.Pending - info.Scheduled - info.Retry - info.Aggregating
if remaining < 0 {
return 0
}
return remaining
}
func graphResumeCursor(after int64, candidates []graphRecoveryCandidate, settled map[string]bool) int64 {
for _, c := range candidates {
if !settled[c.ID] {
break
}
after = c.SeqID
}
return after
}
func graphRecoveryTaskID(tenant uint64, kbID string, candidate graphRecoveryCandidate, revision string) string {
identity := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s:%s:%s", tenant, kbID, candidate.ID, candidate.ContentHash, revision)))
return "native-graph-" + hex.EncodeToString(identity[:])
}
func recordGraphFailure(db *gorm.DB, tenant uint64, kbID, revision, taskID string, c graphRecoveryCandidate, reason string) error {
var failure graphRecoveryFailure
err := db.First(&failure, "task_id=?", taskID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
failure = graphRecoveryFailure{TaskID: taskID, TenantID: tenant, KnowledgeBaseID: kbID, ChunkID: c.ID, KnowledgeID: c.KnowledgeID, ContentHash: c.ContentHash, Revision: revision, FirstFailedAt: time.Now().UTC()}
} else if err != nil {
return err
}
if failure.FirstFailedAt.IsZero() {
failure.FirstFailedAt = time.Now().UTC()
}
failure.State, failure.Reason, failure.UpdatedAt = "failed", reason, time.Now().UTC()
return db.Save(&failure).Error
}
func reserveGraphDispatch(db *gorm.DB, tenant uint64, kbID, revision, taskID string, c graphRecoveryCandidate) (bool, error) {
var prior graphRecoveryFailure
err := db.First(&prior, "task_id=?", taskID).Error
if err == nil {
// An absent Redis task is not permission to repeat an earlier attempt.
return false, recordGraphFailure(db, tenant, kbID, revision, taskID, c, "task_missing_after_dispatch")
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
now := time.Now().UTC()
entry := graphRecoveryFailure{TaskID: taskID, TenantID: tenant, KnowledgeBaseID: kbID, ChunkID: c.ID, KnowledgeID: c.KnowledgeID, ContentHash: c.ContentHash, Revision: revision, State: "dispatch_requested", UpdatedAt: now}
if err := db.Create(&entry).Error; err != nil {
return false, err
}
return true, nil
}
func trackGraphTask(db *gorm.DB, tenant uint64, kbID, revision, taskID string, c graphRecoveryCandidate) error {
entry := graphRecoveryFailure{TaskID: taskID, TenantID: tenant, KnowledgeBaseID: kbID, ChunkID: c.ID, KnowledgeID: c.KnowledgeID, ContentHash: c.ContentHash, Revision: revision, State: "dispatch_requested", UpdatedAt: time.Now().UTC()}
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&entry).Error
}
func graphArchivedReason(lastError string) string {
message := strings.ToLower(lastError)
if strings.Contains(message, "graph_needs_review") {
return "relation_coverage_review_required"
}
if strings.Contains(message, "read response") && (strings.Contains(message, "context deadline exceeded") || strings.Contains(message, "timeout")) {
return "provider_response_timeout"
}
if strings.Contains(message, "context deadline exceeded") || strings.Contains(message, "deadline exceeded") || strings.Contains(message, "timeout") {
return "task_timeout"
}
if strings.Contains(message, "context canceled") || strings.Contains(message, "context cancelled") {
return "task_cancelled"
}
return "archived"
}
// Reconciliation receives only the Inspector read API, never a queue client.
type graphTaskReader interface {
GetTaskInfo(string, string) (*asynq.TaskInfo, error)
}
type graphReconcileReport struct {
Mode string `json:"mode"`
Apply bool `json:"apply"`
TenantID uint64 `json:"tenant_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
Revision string `json:"revision"`
Source graphRecoveryCandidate `json:"source"`
TaskID string `json:"task_id"`
QueueState string `json:"queue_state"`
LedgerState string `json:"ledger_state"`
ProposedState string `json:"proposed_state"`
SourceCurrent bool `json:"source_current"`
CheckpointComplete bool `json:"checkpoint_complete"`
Reason string `json:"reason"`
Blocked bool `json:"blocked"`
WouldUpdate bool `json:"would_update"`
LedgerUpdated bool `json:"ledger_updated"`
Enqueued int `json:"enqueued"`
Retried int `json:"retried"`
ModelCalls int `json:"model_calls"`
}
const graphReconcileCurrentSource = graphCurrentFailureSource + ` AND EXISTS(SELECT 1 FROM knowledges k JOIN knowledge_bases b ON b.id=k.knowledge_base_id AND b.tenant_id=k.tenant_id WHERE k.id=f.knowledge_id AND k.tenant_id=f.tenant_id AND k.knowledge_base_id=f.knowledge_base_id AND k.deleted_at IS NULL AND k.parse_status='completed' AND b.deleted_at IS NULL)`
func graphValidateReconciliation(reconcile bool, chunkID, revision string, retry bool, retryReason string) error {
if reconcile && (chunkID == "" || strings.TrimSpace(revision) == "" || retry || retryReason != "") {
return errors.New("reconcile-only requires one exact source, GRAPH_BACKFILL_REVISION and no retry flags")
}
return nil
}
func graphReconcileSourceStatus(db *gorm.DB, taskID string) (current, complete bool, err error) {
var status struct {
Current, Complete bool
}
result := db.Raw("SELECT ("+graphReconcileCurrentSource+") AS current,("+graphFailureCheckpoint+") AS complete FROM native_graph_backfill_failures f WHERE f.task_id=?", taskID).Scan(&status)
if result.Error != nil {
return false, false, errors.New("cannot validate graph source and checkpoint")
}
if result.RowsAffected != 1 {
return false, false, errors.New("graph reconciliation ledger disappeared")
}
return status.Current, status.Complete, nil
}
func reconcileGraphTask(db *gorm.DB, reader graphTaskReader, tenant uint64, kbID, revision string, c graphRecoveryCandidate, apply bool) (graphReconcileReport, error) {
report := graphReconcileReport{Mode: "reconcile_only", Apply: apply, TenantID: tenant, KnowledgeBaseID: kbID, Revision: revision, Source: c,
TaskID: graphRecoveryTaskID(tenant, kbID, c, revision), QueueState: "unknown", Blocked: true}
var ledger graphRecoveryFailure
err := db.First(&ledger, "task_id=? AND tenant_id=? AND knowledge_base_id=? AND chunk_id=? AND knowledge_id=? AND content_hash=? AND revision=?",
report.TaskID, tenant, kbID, c.ID, c.KnowledgeID, c.ContentHash, revision).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
report.Reason = "ledger_missing_or_identity_mismatch"
return report, nil
}
if err != nil {
return report, errors.New("cannot read exact graph ledger")
}
report.LedgerState, report.ProposedState = ledger.State, ledger.State
report.SourceCurrent, report.CheckpointComplete, err = graphReconcileSourceStatus(db, report.TaskID)
if err != nil {
return report, err
}
if !report.SourceCurrent {
report.Reason = "source_changed_or_unavailable"
return report, nil
}
if ledger.State == "failed" && strings.HasPrefix(ledger.Reason, "semantic_review_rejected") {
report.Reason = "manual_semantic_review_required"
return report, nil
}
info, err := reader.GetTaskInfo(types.QueueGraph, report.TaskID)
if errors.Is(err, asynq.ErrTaskNotFound) || errors.Is(err, asynq.ErrQueueNotFound) || (err == nil && info == nil) {
report.QueueState, report.Reason = "missing", "task_missing_after_dispatch"
return report, nil
}
if err != nil {
report.Reason = "queue_inspection_failed"
return report, errors.New("cannot inspect graph task; no reconciliation applied")
}
report.QueueState = info.State.String()
if info.ID != report.TaskID || info.Queue != types.QueueGraph || info.Type != types.TypeChunkExtract ||
!graphTaskMatchesReviewedSource(info.Payload, tenant, kbID, c) || info.MaxRetry != 0 {
report.Reason = "queue_task_identity_mismatch"
return report, nil
}
if info.State != asynq.TaskStateArchived && info.State != asynq.TaskStateCompleted {
report.Blocked, report.Reason = false, "task_not_terminal"
return report, nil
}
// A worker can commit between the first source read and terminal queue read.
report.SourceCurrent, report.CheckpointComplete, err = graphReconcileSourceStatus(db, report.TaskID)
if err != nil {
return report, err
}
if !report.SourceCurrent {
report.Reason = "source_changed_or_unavailable"
return report, nil
}
if report.CheckpointComplete {
report.ProposedState, report.Reason, report.Blocked = "resolved", "native_checkpoint_verified", false
} else {
report.ProposedState, report.Reason = "failed", "completed_without_native_checkpoint"
if info.State == asynq.TaskStateArchived {
report.Reason = graphArchivedReason(info.LastErr)
}
}
if ledger.State == "superseded" || (ledger.State == "resolved" && report.ProposedState != "resolved") {
report.ProposedState, report.Reason, report.Blocked = ledger.State, "ledger_terminal_conflict", true
return report, nil
}
// Preserve earlier failure and retry history; refine only an empty/generic
// reason. Resolution never erases the original failure reason or timestamp.
reason := ledger.Reason
if report.ProposedState == "failed" && (reason == "" || reason == "archived") {
reason = report.Reason
}
report.WouldUpdate = ledger.State != report.ProposedState || ledger.Reason != reason ||
(report.ProposedState == "failed" && ledger.FirstFailedAt.IsZero())
if !apply || !report.WouldUpdate {
return report, nil
}
updates := map[string]any{"state": report.ProposedState, "reason": reason, "updated_at": time.Now().UTC()}
if report.ProposedState == "failed" && ledger.FirstFailedAt.IsZero() {
failedAt := info.LastFailedAt
if failedAt.IsZero() {
failedAt = time.Now().UTC()
}
updates["first_failed_at"] = failedAt
}
checkpoint := graphFailureCheckpoint
if !report.CheckpointComplete {
checkpoint = "NOT " + checkpoint
}
// Revalidate source, checkpoint, and ledger state in the update itself so a
// concurrent source edit or recovery cannot be overwritten by this snapshot.
result := db.Table("native_graph_backfill_failures AS f").Where("f.task_id=? AND f.state=? AND f.reason=? AND f.manual_retries=? AND f.updated_at=?",
report.TaskID, ledger.State, ledger.Reason, ledger.ManualRetries, ledger.UpdatedAt).
Where("f.tenant_id=? AND f.knowledge_base_id=? AND f.chunk_id=? AND f.knowledge_id=? AND f.content_hash=? AND f.revision=?", tenant, kbID, c.ID, c.KnowledgeID, c.ContentHash, revision).
Where(graphReconcileCurrentSource).Where(checkpoint).Updates(updates)
if result.Error != nil {
return report, errors.New("cannot update exact graph ledger")
}
if result.RowsAffected != 1 {
report.Reason, report.Blocked = "reconciliation_state_changed", true
return report, nil
}
report.LedgerUpdated = true
return report, nil
}
func graphRecoveryRedisOptions() (asynq.RedisClientOpt, error) {
redisDB, err := strconv.Atoi(os.Getenv("REDIS_DB"))
if err != nil || os.Getenv("REDIS_ADDR") == "" {
return asynq.RedisClientOpt{}, errors.New("runtime Redis configuration is missing")
}
return asynq.RedisClientOpt{Addr: os.Getenv("REDIS_ADDR"), Username: os.Getenv("REDIS_USERNAME"), Password: os.Getenv("REDIS_PASSWORD"), DB: redisDB, TLSConfig: common.RedisTLSConfig()}, nil
}
// The checkpoint is queried again after observing a completed task because a
// worker may have committed between candidate selection and Redis inspection.
func graphCandidateCompleted(db *gorm.DB, tenant uint64, kbID string, c graphRecoveryCandidate) (bool, error) {
var count int64
err := db.Table("native_graph_records").Where("tenant_id=? AND knowledge_base_id=? AND source_table='knowledgegraphchunkstate' AND payload->>'origin'='native_extraction_v1' AND payload->>'extraction_status'='complete' AND CAST(COALESCE(payload->>'relation_count','0') AS INTEGER)>0 AND payload->>'native_chunk_id'=? AND payload->>'native_knowledge_id'=? AND payload->>'source_content_hash'=?", tenant, kbID, c.ID, c.KnowledgeID, c.ContentHash).Count(&count).Error
return count > 0, err
}
func graphFailureRetry(db *gorm.DB, queue graphRecoveryQueue, taskID, reason string, slots int) (bool, error) {
if slots <= 0 {
return false, nil
}
if len(strings.TrimSpace(reason)) < 12 {
return false, errors.New("manual retry requires a reason describing the investigated failure and fix")
}
var failure graphRecoveryFailure
if err := db.First(&failure, "task_id=?", taskID).Error; err != nil {
return false, err
}
if failure.ManualRetries >= 1 {
return false, nil
}
// Reserve the one allowed manual retry before sending to Redis. A crash
// here fails closed and leaves a visible record instead of retrying forever.
failure.ManualRetries++
failure.State, failure.RetryReason, failure.UpdatedAt = "retry_requested", reason, time.Now().UTC()
if err := db.Save(&failure).Error; err != nil {
return false, err
}
if err := queue.RunTask(types.QueueGraph, taskID); err != nil {
return false, err
}
return true, nil
}
func graphRecoveryDSN() (string, error) {
if dsn := os.Getenv("GRAPH_BACKFILL_DSN"); dsn != "" {
return dsn, nil
}
if os.Getenv("DB_HOST") == "" || os.Getenv("DB_NAME") == "" || os.Getenv("DB_USER") == "" {
return "", errors.New("runtime PostgreSQL configuration is missing")
}
port := os.Getenv("DB_PORT")
if port == "" {
port = "5432"
}
u := url.URL{Scheme: "postgresql", User: url.UserPassword(os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD")), Host: net.JoinHostPort(os.Getenv("DB_HOST"), port), Path: "/" + os.Getenv("DB_NAME")}
q := u.Query()
ssl := os.Getenv("DB_SSL_MODE")
if ssl == "" {
ssl = "disable"
}
q.Set("sslmode", ssl)
u.RawQuery = q.Encode()
return u.String(), nil
}
func runGraphRecovery() error {
tenant := flag.Uint64("tenant", 0, "required tenant")
kbID := flag.String("kb", "", "required knowledge base UUID")
limit := flag.Int("limit", 20, "bounded chunks to enqueue (1-200)")
after := flag.Int64("after", 0, "resume after this chunk sequence ID")
apply := flag.Bool("apply", false, "apply the selected mode; otherwise report only")
reconcileOnly := flag.Bool("reconcile-only", false, "reconcile one existing exact-source ledger only; never enqueue or retry")
retry := flag.Bool("retry-failed", false, "one audited manual retry for tasks still archived in Redis")
retryReason := flag.String("retry-reason", "", "audited reason for the single permitted manual retry")
maxOutstanding := flag.Int("max-outstanding", 2, "maximum outstanding tasks in the shared graph queue (1-20)")
chunkID := flag.String("chunk", "", "only this reviewed chunk; requires knowledge and source-sha256")
knowledgeID := flag.String("knowledge", "", "reviewed source document UUID")
sourceHash := flag.String("source-sha256", "", "reviewed exact source SHA-256")
flag.Parse()
revision := os.Getenv("GRAPH_BACKFILL_REVISION")
if flag.NArg() != 0 {
return errors.New("unexpected positional arguments")
}
if err := graphValidateReconciliation(*reconcileOnly, *chunkID, revision, *retry, *retryReason); err != nil {
return err
}
if *tenant == 0 || *limit < 1 || *limit > 200 || *after < 0 || *maxOutstanding < 1 || *maxOutstanding > 20 {
return errors.New("tenant, limit 1-200 and non-negative cursor are required")
}
if *retry && len(strings.TrimSpace(*retryReason)) < 12 {
return errors.New("retry-failed requires an audited retry-reason describing the investigated failure and fix")
}
if _, err := uuid.Parse(*kbID); err != nil {
return errors.New("invalid knowledge base UUID")
}
if err := graphValidateExactScope(*chunkID, *knowledgeID, *sourceHash, *after, *limit, *maxOutstanding); err != nil {
return err
}
var exact []graphRecoveryCandidate
if *chunkID != "" {
exact = append(exact, graphRecoveryCandidate{ID: *chunkID, KnowledgeID: *knowledgeID, ContentHash: *sourceHash})
}
dsn, err := graphRecoveryDSN()
if err != nil {
return err
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
return errors.New("cannot open graph recovery database")
}
sqlDB, err := db.DB()
if err != nil {
return err
}
defer sqlDB.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
db = db.WithContext(ctx)
if *apply {
if *reconcileOnly && os.Getenv("GRAPH_LEDGER_RECONCILE_VERIFIED") != "1" {
return errors.New("review the reconciliation preview first; then set GRAPH_LEDGER_RECONCILE_VERIFIED=1")
}
if !*reconcileOnly && os.Getenv("GRAPH_NATIVE_WRITE_VERIFIED") != "1" {
return errors.New("deploy and verify native graph persistence first; then set GRAPH_NATIVE_WRITE_VERIFIED=1")
}
// Hold a session lock on a dedicated connection while ledger writes use
// autocommit. Redis calls must never precede a durable attempt record.
lockConn, err := sqlDB.Conn(ctx)
if err != nil {
return err
}
defer lockConn.Close()
var locked bool
if err := lockConn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock(20260919, 7351)").Scan(&locked); err != nil {
return err
}
if !locked {
return errors.New("another graph backfill dispatcher is active")
}
defer func() {
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = lockConn.ExecContext(unlockCtx, "SELECT pg_advisory_unlock(20260919, 7351)")
}()
}
if *reconcileOnly {
if !db.Migrator().HasTable(&graphRecoveryFailure{}) {
return errors.New("graph failure ledger is missing; reconciliation will not create it")
}
redisOpt, err := graphRecoveryRedisOptions()
if err != nil {
return err
}
inspector := asynq.NewInspector(redisOpt)
defer inspector.Close()
report, err := reconcileGraphTask(db, inspector, *tenant, *kbID, revision, exact[0], *apply)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(report)
}
var kb struct {
Name, SummaryModelID string
Enabled bool
}
result := db.Raw(`SELECT name,summary_model_id,COALESCE((extract_config->>'enabled')::boolean,false) AS enabled FROM knowledge_bases WHERE tenant_id=? AND id=? AND deleted_at IS NULL`, *tenant, *kbID).Scan(&kb)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errors.New("knowledge base not found in tenant")
}
if *apply && (!strings.EqualFold(os.Getenv("LEGACY_GRAPH_ENABLE"), "true") || !kb.Enabled || kb.SummaryModelID == "") {
return errors.New("native graph backend, knowledge base extraction and summary model must be enabled first")
}
hasFailures := db.Migrator().HasTable(&graphRecoveryFailure{})
if *apply && !hasFailures {
if err := db.AutoMigrate(&graphRecoveryFailure{}); err != nil {
return err
}
hasFailures = true
}
if *apply {
if _, err := graphUnresolvedFailures(db, *tenant, *kbID, revision, true); err != nil {
return err
}
}
var coverage struct{ Documents, TextChunks, NativeCompleted, MappedChunks int64 }
err = db.Raw(`WITH completed AS NOT MATERIALIZED(SELECT payload FROM native_graph_records WHERE tenant_id=? AND knowledge_base_id=? AND source_table='knowledgegraphchunkstate' AND payload->>'origin'='native_extraction_v1' AND payload->>'extraction_status'='complete' AND CAST(COALESCE(payload->>'relation_count','0') AS INTEGER)>0)
SELECT count(DISTINCT c.knowledge_id) AS documents,count(*) AS text_chunks,
count(*) FILTER(WHERE EXISTS(SELECT 1 FROM completed r WHERE r.payload->>'native_chunk_id'=c.id AND r.payload->>'native_knowledge_id'=c.knowledge_id AND r.payload->>'source_content_hash'=encode(sha256(convert_to(c.content,'UTF8')),'hex'))) AS native_completed,
count(*) FILTER(WHERE EXISTS(SELECT 1 FROM native_graph_sources s WHERE s.tenant_id=c.tenant_id AND s.knowledge_base_id=c.knowledge_base_id AND s.native_chunk_id=c.id AND s.native_content_sha256=encode(sha256(convert_to(c.content,'UTF8')),'hex'))) AS mapped_chunks
FROM chunks c JOIN knowledges k ON k.id=c.knowledge_id AND k.tenant_id=c.tenant_id AND k.knowledge_base_id=c.knowledge_base_id AND k.deleted_at IS NULL
WHERE c.tenant_id=? AND c.knowledge_base_id=? AND c.deleted_at IS NULL AND c.is_enabled AND c.chunk_type='text'`, *tenant, *kbID, *tenant, *kbID).Scan(&coverage).Error
if err != nil {
return err
}
candidates, err := graphRecoveryCandidates(db, *tenant, *kbID, revision, *after, *limit, hasFailures, *retry, exact...)
if err != nil {
return err
}
report := map[string]any{"tenant_id": *tenant, "knowledge_base_id": *kbID, "knowledge_base_name": kb.Name, "graph_enabled": kb.Enabled, "coverage": coverage, "candidate_count": len(candidates), "after": *after, "next_cursor": *after, "apply": *apply, "enqueued": 0, "already_queued": 0, "retried": 0, "archived": 0, "candidates": candidates}
if !*apply {
if hasFailures {
failures, err := graphUnresolvedFailures(db, *tenant, *kbID, revision, false)
if err != nil {
return err
}
report["failures"], report["failure_count"] = failures, len(failures)
}
return json.NewEncoder(os.Stdout).Encode(report)
}
redisOpt, err := graphRecoveryRedisOptions()
if err != nil {
return err
}
client := asynq.NewClient(redisOpt)
defer client.Close()
inspector := asynq.NewInspector(redisOpt)
defer inspector.Close()
enqueued, existing, retried, archived, deferred := 0, 0, 0, 0, 0
queueInfo, err := inspector.GetQueueInfo(types.QueueGraph)
if err != nil && !errors.Is(err, asynq.ErrQueueNotFound) {
return err
}
if queueInfo == nil {
queueInfo = &asynq.QueueInfo{}
}
slots := graphQueueSlots(queueInfo, *maxOutstanding)
settled := make(map[string]bool)
for _, c := range candidates {
if len(exact) > 0 {
// Recheck immediately before dispatch. The worker checks the same
// immutable identity before calling the model if a source changes later.
if _, err := graphRecoveryCandidates(db, *tenant, *kbID, revision, 0, 1, hasFailures, *retry, exact...); err != nil {
return err
}
}
taskID := graphRecoveryTaskID(*tenant, *kbID, c, revision)
info, inspectErr := inspector.GetTaskInfo(types.QueueGraph, taskID)
if inspectErr != nil && !errors.Is(inspectErr, asynq.ErrTaskNotFound) && !errors.Is(inspectErr, asynq.ErrQueueNotFound) {
return inspectErr
}
if info != nil {
if len(exact) > 0 && (!graphTaskMatchesReviewedSource(info.Payload, *tenant, *kbID, c) || info.MaxRetry != 0) {
return errors.New("existing graph task does not have the reviewed source and single-execution guard; inspect it without resetting the failure ledger")
}
existing++
if err := trackGraphTask(db, *tenant, *kbID, revision, taskID, c); err != nil {
return err
}
if info.State == asynq.TaskStateArchived || info.State == asynq.TaskStateCompleted {
verified, err := graphCandidateCompleted(db, *tenant, *kbID, c)
if err != nil {
return err
}
if verified {
settled[c.ID] = true
continue
}
reason := "completed_without_native_checkpoint"
if info.State == asynq.TaskStateArchived {
reason = graphArchivedReason(info.LastErr)
archived++
}
if err := recordGraphFailure(db, *tenant, *kbID, revision, taskID, c, reason); err != nil {
return err
}
settled[c.ID] = true
if *retry && info.State == asynq.TaskStateArchived {
didRetry, err := graphFailureRetry(db, inspector, taskID, *retryReason, slots)
if err != nil {
return err
}
if didRetry {
retried++
slots--
settled[c.ID] = false
}
}
}
continue
}
if slots == 0 {
deferred++
continue
}
reserved, err := reserveGraphDispatch(db, *tenant, *kbID, revision, taskID, c)
if err != nil {
return err
}
if !reserved {
settled[c.ID] = true
continue
}
// KnowledgeID is intentionally empty: recovery has no ingestion slot
// to decrement and must not finalize a concurrent document upload.
payload, err := json.Marshal(types.ExtractChunkPayload{TenantID: *tenant, ChunkID: c.ID, ModelID: kb.SummaryModelID, ChunkIndex: c.ChunkIndex})
if len(exact) > 0 {
payload, err = json.Marshal(types.ExtractChunkPayload{TenantID: *tenant, ChunkID: c.ID, ModelID: kb.SummaryModelID, ChunkIndex: c.ChunkIndex,
ExpectedSource: &types.GraphSourceIdentity{KnowledgeBaseID: *kbID, KnowledgeID: c.KnowledgeID, ContentSHA256: c.ContentHash}})
}
if err != nil {
return err
}
maxRetry, timeout := 3, 10*time.Minute
if len(exact) > 0 {
maxRetry, timeout = 0, 3*time.Minute
}
_, err = client.EnqueueContext(ctx, asynq.NewTask(types.TypeChunkExtract, payload), asynq.Queue(types.QueueGraph), asynq.TaskID(taskID), asynq.MaxRetry(maxRetry), asynq.Timeout(timeout), asynq.Retention(24*time.Hour))
if errors.Is(err, asynq.ErrTaskIDConflict) {
existing++
// Another producer raced with this dispatcher. Count the slot
// conservatively; the next poll will inspect its actual state.
slots--
continue
}
if err != nil {
return err
}
enqueued++
slots--
}
report["enqueued"], report["already_queued"], report["retried"] = enqueued, existing, retried
// Do not let a manual resume skip deferred or still-running work. Watch
// always starts at zero and additionally uses persisted failure exclusions.
report["next_cursor"] = graphResumeCursor(*after, candidates, settled)
report["archived"] = archived
report["deferred_for_queue_limit"], report["max_outstanding"] = deferred, *maxOutstanding
report["queue_paused"] = queueInfo.Paused
report["queue_limit_scope"] = "serialized_backfill_dispatchers; other graph producers may enqueue concurrently"
failures, err := graphUnresolvedFailures(db, *tenant, *kbID, revision, true)
if err != nil {
return err
}
report["failures"], report["failure_count"] = failures, len(failures)
return json.NewEncoder(os.Stdout).Encode(report)
}
func graphBatchStatus(report map[string]any) (complete, blocked bool) {
coverage, _ := report["coverage"].(map[string]any)
textChunks, totalOK := coverage["TextChunks"].(float64)
completedChunks, completedOK := coverage["NativeCompleted"].(float64)
candidates, candidatesOK := report["candidate_count"].(float64)
archived, _ := report["archived"].(float64)
failures, _ := report["failure_count"].(float64)
paused, _ := report["queue_paused"].(bool)
valid := totalOK && completedOK && candidatesOK && textChunks >= 0 && completedChunks >= 0 && completedChunks <= textChunks && candidates >= 0 && archived >= 0 && failures >= 0
complete = valid && completedChunks == textChunks && candidates == 0 && failures == 0 && archived == 0
blocked = archived > 0 || failures > 0 || !valid || (!complete && (candidates == 0 || paused))
return
}
// A restart resumes from verified database checkpoints. Each knowledge base has
// only one bounded window in flight; failed windows require explicit recovery.
func watchGraphRecovery() error {
flags := flag.NewFlagSet("watch", flag.ContinueOnError)
tenant := flags.Uint64("tenant", 0, "required tenant")
kbs := flags.String("kbs", "", "comma-separated knowledge base UUIDs")
limit := flags.Int("limit", 2, "maximum candidate window per knowledge base (1-20)")
maxOutstanding := flags.Int("max-outstanding", 2, "maximum outstanding tasks across the shared graph queue (1-20)")
interval := flags.Duration("interval", time.Minute, "poll interval (at least 30s)")
stateFile := flags.String("state", "/data/progress.json", "durable progress report")
if err := flags.Parse(os.Args[2:]); err != nil {
return err
}
if *tenant == 0 || *limit < 1 || *limit > 20 || *interval < 30*time.Second || *kbs == "" || *maxOutstanding < 1 || *maxOutstanding > 20 {
return errors.New("watch requires tenant, knowledge bases, limit 1-20 and interval >=30s")
}
ids := strings.Split(*kbs, ",")
if len(ids) > 10 {
return errors.New("watch supports at most 10 knowledge bases")
}
for _, id := range ids {
if _, err := uuid.Parse(id); err != nil {
return errors.New("invalid knowledge base in watch scope")
}
}
executable, err := os.Executable()
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
for {
reports := []map[string]any{}
complete, blocked := true, false
for _, id := range ids {
commandCtx, cancel := context.WithTimeout(ctx, 6*time.Minute)
command := exec.CommandContext(commandCtx, executable, "-tenant", strconv.FormatUint(*tenant, 10), "-kb", id, "-limit", strconv.Itoa(*limit), "-max-outstanding", strconv.Itoa(*maxOutstanding), "-apply")
output, commandErr := command.Output()
cancel()
var report map[string]any
if commandErr != nil || json.Unmarshal(output, &report) != nil {
report = map[string]any{"knowledge_base_id": id, "error": "batch command failed; check runtime and model configuration"}
blocked, complete = true, false
} else {
delete(report, "candidates")
batchComplete, batchBlocked := graphBatchStatus(report)
complete = complete && batchComplete
blocked = blocked || batchBlocked
if batchBlocked {
report["attention"] = "Native coverage is incomplete or extraction failures need recovery"
}
}
reports = append(reports, report)
if ctx.Err() != nil {
return nil
}
}
status := "running"
if complete {
status = "complete"
} else if blocked {
status = "needs_attention"
}
state := map[string]any{"status": status, "checked_at": time.Now().UTC(), "tenant_id": *tenant, "batch_limit_per_kb": *limit, "max_outstanding": *maxOutstanding, "knowledge_bases": reports}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(*stateFile), 0700); err != nil {
return err
}
if err = os.WriteFile(*stateFile+".tmp", data, 0600); err != nil {
return err
}
if err = os.Rename(*stateFile+".tmp", *stateFile); err != nil {
return err
}
fmt.Printf("{\"status\":%q,\"knowledge_bases\":%d}\n", status, len(reports))
if complete {
// Keep the successful checkpoint visible without restart-policy loops.
<-ctx.Done()
return nil
}
select {
case <-ctx.Done():
return nil
case <-time.After(*interval):
}
}
}
func main() {
var err error
if len(os.Args) > 1 && os.Args[1] == "--watch" {
err = watchGraphRecovery()
} else {
err = runGraphRecovery()
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}