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
400 lines
15 KiB
Go
400 lines
15 KiB
Go
// Package service: knowledge housekeeping.
|
|
//
|
|
// HousekeepingService periodically scans for knowledge rows that have been
|
|
// stuck in "processing" longer than any reasonable execution window and
|
|
// marks them as failed. This is the safety net that catches anything the
|
|
// other defences (asynq retry, dead-letter callback, image_multimodal
|
|
// finalize-on-last-attempt) miss — for example:
|
|
//
|
|
// - Worker process killed mid-handler before any defer could run.
|
|
// - DocReader call genuinely exceeding DocReaderCallTimeout AND the
|
|
// worker subsequently being lost before retry kicks in.
|
|
// - Multimodal Redis counter set to N but ALL N image tasks failing in
|
|
// ways that bypass finalize (extremely rare; defence-in-depth here).
|
|
//
|
|
// Without this sweep, a single unlucky failure mode can leave a knowledge
|
|
// row in "processing" forever — invisible to users except as a permanent
|
|
// spinner. With this sweep the worst-case latency from stall to user-
|
|
// visible failure is bounded to ~1 stale-threshold + 1 sweep interval.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/config"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/robfig/cron/v3"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// HousekeepingService runs background sweeps to recover stuck rows.
|
|
type HousekeepingService struct {
|
|
db *gorm.DB
|
|
cfg *config.Config
|
|
cron *cron.Cron
|
|
|
|
// inspector lets the sweep distinguish a genuinely orphaned row from
|
|
// one whose enrichment subtasks are merely backlogged behind a busy
|
|
// queue (no span heartbeat yet because no worker has picked them up).
|
|
// nil-safe — a nil inspector disables only the transient queue check.
|
|
// Durable Wiki ownership in task_pending_ops is always probed through db,
|
|
// so the sweep never falls back to the span/updated_at heuristics alone.
|
|
inspector interfaces.TaskInspector
|
|
|
|
mu sync.Mutex
|
|
started bool
|
|
}
|
|
|
|
// NewHousekeepingService constructs a HousekeepingService. It does NOT start
|
|
// the cron — call Start in the application bootstrap so a misconfigured
|
|
// cron schedule cannot prevent the rest of the service from coming up.
|
|
func NewHousekeepingService(
|
|
db *gorm.DB, cfg *config.Config, inspector interfaces.TaskInspector,
|
|
) *HousekeepingService {
|
|
return &HousekeepingService{
|
|
db: db,
|
|
cfg: cfg,
|
|
inspector: inspector,
|
|
cron: cron.New(cron.WithSeconds(), cron.WithChain(
|
|
cron.Recover(cron.DefaultLogger),
|
|
)),
|
|
}
|
|
}
|
|
|
|
// Start registers the sweep schedule and begins the background runner.
|
|
// Idempotent — repeated calls are a no-op so wiring code can call Start
|
|
// without coordinating ordering.
|
|
func (h *HousekeepingService) Start(ctx context.Context) error {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.started {
|
|
return nil
|
|
}
|
|
if !housekeepingEnabled() {
|
|
logger.Infof(ctx, "[Housekeeping] disabled via WEKNORA_HOUSEKEEPING_ENABLED=false")
|
|
return nil
|
|
}
|
|
// Every 5 minutes — frequent enough that user-visible recovery latency
|
|
// is acceptable, infrequent enough that the SQL sweep is invisible to
|
|
// query load even on large knowledge tables.
|
|
if _, err := h.cron.AddFunc("0 */5 * * * *", func() {
|
|
// Use Background so a cancelled bootstrap ctx doesn't stop sweeps.
|
|
h.runSweep(context.Background())
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
h.cron.Start()
|
|
h.started = true
|
|
logger.Infof(ctx, "[Housekeeping] started with 5-minute sweep")
|
|
return nil
|
|
}
|
|
|
|
// Stop halts the cron and waits for in-flight sweeps to finish.
|
|
func (h *HousekeepingService) Stop() {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if !h.started {
|
|
return
|
|
}
|
|
c := h.cron.Stop()
|
|
<-c.Done()
|
|
h.started = false
|
|
}
|
|
|
|
// runSweep is exported on the type for testability — tests can drive a
|
|
// single sweep without waiting for the cron tick.
|
|
func (h *HousekeepingService) runSweep(ctx context.Context) {
|
|
threshold := h.staleThreshold()
|
|
cutoff := time.Now().Add(-threshold)
|
|
|
|
// Sweep A: knowledge stuck in "pending", "processing", or "finalizing".
|
|
//
|
|
// Two-stage check is critical here: knowledge.updated_at advances
|
|
// only at parse_status transitions, but a long stage (DocReader on
|
|
// a 500MB PDF, embedding 5K chunks) can run for an hour with no
|
|
// status change. Using updated_at alone would falsely kill that
|
|
// run. So we OR-combine knowledge.updated_at with the most recent
|
|
// span row's updated_at — every Begin/End/Fail/Skip from
|
|
// SpanTracker bumps the span row, so an actively-progressing
|
|
// pipeline always has a recent span heartbeat even when the
|
|
// parent knowledge row is "frozen" mid-stage.
|
|
//
|
|
// Knowledge rows with no spans at all (lite mode, in-flight tasks
|
|
// from before this code shipped) fall back to the simple
|
|
// updated_at check — they have no heartbeat to consult.
|
|
// Include 'pending' so a task whose enqueue was lost is eventually
|
|
// recovered too; filterOutQueued below protects legitimately backlogged
|
|
// tasks. Include 'finalizing' alongside 'processing': finalizing rows still
|
|
// consume LLM compute via enrichment subtasks (summary/question/graph),
|
|
// and the same stall modes (subtask worker dies, retry budget exhausted
|
|
// without decrementing the counter) leave the row hanging just as
|
|
// visibly. Housekeeping promotes both states to 'failed' once the
|
|
// span heartbeat is older than the threshold.
|
|
var candidates []types.Knowledge
|
|
if err := h.db.WithContext(ctx).
|
|
Where("parse_status IN ? AND updated_at < ?",
|
|
[]string{types.ParseStatusPending, types.ParseStatusProcessing, types.ParseStatusFinalizing}, cutoff).
|
|
Find(&candidates).Error; err != nil {
|
|
logger.Warnf(ctx, "[Housekeeping] knowledge candidate query failed: %v", err)
|
|
return
|
|
}
|
|
|
|
stuck := h.filterByLastSpanActivity(ctx, candidates, cutoff)
|
|
spanSkipped := len(candidates) - len(stuck)
|
|
|
|
// Second-stage gate: a row can have a stale span heartbeat yet still
|
|
// be perfectly healthy when its enrichment subtasks (summary /
|
|
// question / graph / wiki) are merely backlogged behind a busy queue
|
|
// — no worker has picked them up, so no span has been written since
|
|
// post-process fanned them out. Killing such a row is the false-
|
|
// positive users hit under heavy upload bursts. Drop any candidate
|
|
// that still has a queued/active task referencing it; only rows with
|
|
// nothing left in the queue are treated as genuinely orphaned.
|
|
stuck, queueSkipped := h.filterOutQueued(ctx, stuck)
|
|
|
|
if len(stuck) > 0 {
|
|
stuckIDs := make([]string, 0, len(stuck))
|
|
for _, k := range stuck {
|
|
stuckIDs = append(stuckIDs, k.ID)
|
|
}
|
|
res := h.db.WithContext(ctx).Model(&types.Knowledge{}).
|
|
Where("id IN ? AND parse_status IN ?", stuckIDs,
|
|
[]string{types.ParseStatusPending, types.ParseStatusProcessing, types.ParseStatusFinalizing}).
|
|
Updates(map[string]interface{}{
|
|
"parse_status": types.ParseStatusFailed,
|
|
"error_message": "task stuck in processing > " + threshold.String() + ", recovered by housekeeping",
|
|
"pending_subtasks_count": 0,
|
|
})
|
|
if res.Error != nil {
|
|
logger.Warnf(ctx, "[Housekeeping] knowledge sweep update failed: %v", res.Error)
|
|
} else if res.RowsAffected > 0 {
|
|
logger.Infof(ctx, "[Housekeeping] recovered %d stuck knowledge rows (threshold=%s)",
|
|
res.RowsAffected, threshold)
|
|
}
|
|
}
|
|
if spanSkipped > 0 {
|
|
// Visibility into "we considered killing N rows but their
|
|
// span tree showed they're still progressing". Ops can grep
|
|
// for this if they suspect housekeeping over- or under-fires.
|
|
logger.Infof(ctx,
|
|
"[Housekeeping] %d candidate(s) skipped — span heartbeat within threshold",
|
|
spanSkipped)
|
|
}
|
|
if queueSkipped > 0 {
|
|
// Visibility into "stale span heartbeat but tasks still queued"
|
|
// — i.e. backpressure, not a stuck row. Persistent counts here
|
|
// mean the queue is the bottleneck (raise the matching per-pool or
|
|
// shared asynq concurrency, or document_process_timeout), not that
|
|
// housekeeping misfires.
|
|
logger.Infof(ctx,
|
|
"[Housekeeping] %d candidate(s) skipped — tasks still queued (backpressure, not stuck)",
|
|
queueSkipped)
|
|
}
|
|
|
|
// Sweep B: knowledge summary stuck. Summary is post-parse; threshold
|
|
// is shorter because summary tasks are bounded by a single LLM call.
|
|
// No span heartbeat exists for the summary stage (it lives in a
|
|
// downstream asynq task), so we accept the original simple check.
|
|
summaryCutoff := time.Now().Add(-1 * time.Hour)
|
|
resSummary := h.db.WithContext(ctx).Model(&types.Knowledge{}).
|
|
Where("summary_status = ? AND updated_at < ?", types.SummaryStatusProcessing, summaryCutoff).
|
|
Update("summary_status", types.SummaryStatusFailed)
|
|
if resSummary.Error != nil {
|
|
logger.Warnf(ctx, "[Housekeeping] summary sweep failed: %v", resSummary.Error)
|
|
} else if resSummary.RowsAffected > 0 {
|
|
logger.Infof(ctx, "[Housekeeping] recovered %d stuck summary rows", resSummary.RowsAffected)
|
|
}
|
|
}
|
|
|
|
// filterByLastSpanActivity returns the subset of candidates whose most
|
|
// recent span row predates `cutoff` — i.e. genuinely stuck. Candidates
|
|
// with no span rows at all also pass through (they're lite-mode or
|
|
// pre-instrumentation tasks; the simple updated_at check already proved
|
|
// them stuck and we have no heartbeat to override that).
|
|
func (h *HousekeepingService) filterByLastSpanActivity(ctx context.Context, candidates []types.Knowledge, cutoff time.Time) []types.Knowledge {
|
|
if len(candidates) == 0 {
|
|
return candidates
|
|
}
|
|
ids := make([]string, 0, len(candidates))
|
|
for _, k := range candidates {
|
|
ids = append(ids, k.ID)
|
|
}
|
|
|
|
// We scan MAX(updated_at) as string then parse client-side. That
|
|
// dodges the SQLite driver's well-known refusal to auto-convert
|
|
// aggregate datetime values into time.Time on its own — Postgres
|
|
// happily round-trips, but the same query shape must work in
|
|
// Lite mode too. Since we only compare against a cutoff, the
|
|
// parse layer below tries the formats both Postgres and SQLite
|
|
// emit and takes the first that parses.
|
|
type spanHeartbeat struct {
|
|
KnowledgeID string `gorm:"column:knowledge_id"`
|
|
LastSeen string `gorm:"column:last_seen"`
|
|
}
|
|
var beats []spanHeartbeat
|
|
err := h.db.WithContext(ctx).
|
|
Table("knowledge_processing_spans").
|
|
Select("knowledge_id, MAX(updated_at) AS last_seen").
|
|
Where("knowledge_id IN ?", ids).
|
|
Group("knowledge_id").
|
|
Find(&beats).Error
|
|
if err != nil {
|
|
// On query failure, fail safe — assume nothing has a
|
|
// heartbeat (so all candidates are "stuck"). This matches
|
|
// the previous-version behaviour and never under-recovers.
|
|
logger.Warnf(ctx, "[Housekeeping] span heartbeat query failed: %v (will fail safe and recover all candidates)", err)
|
|
return candidates
|
|
}
|
|
heartbeat := make(map[string]time.Time, len(beats))
|
|
for _, b := range beats {
|
|
if t, ok := parseHeartbeatTime(b.LastSeen); ok {
|
|
heartbeat[b.KnowledgeID] = t
|
|
}
|
|
}
|
|
|
|
out := candidates[:0]
|
|
for _, k := range candidates {
|
|
if last, ok := heartbeat[k.ID]; ok && last.After(cutoff) {
|
|
// Active span heartbeat — leave alone.
|
|
continue
|
|
}
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// filterOutQueued returns the subset of candidates that have NO work left
|
|
// in either the durable pending-op table or the queue backend, plus a count
|
|
// of how many were dropped because work still references them. A dropped
|
|
// candidate is "backlogged, not orphaned" — its enrichment subtasks are
|
|
// waiting for a worker, so the missing span heartbeat is expected and
|
|
// recovering it would be a false positive.
|
|
//
|
|
// The asynq inspector alone cannot answer this for Wiki. Wiki ingest work is
|
|
// durable per document in task_pending_ops (dedup_key = knowledge ID), while
|
|
// asynq only carries a per-KB wake-up trigger whose payload has no knowledge
|
|
// ID — and TypeWikiIngest is deliberately absent from the inspector's
|
|
// taskTypesForKnowledgeCancel set anyway. So HasQueuedTasksForKnowledge
|
|
// structurally reports "nothing queued" for a document whose only outstanding
|
|
// work is a queued Wiki ingest, and the sweep force-fails a perfectly healthy
|
|
// row. Probe the durable table directly before consulting the inspector.
|
|
//
|
|
// When no inspector is wired (nil) the durable gate still applies; only the
|
|
// transient queue check is skipped. On probe error we fail safe, but in
|
|
// opposite directions by design: an inspector error KEEPS the candidate as
|
|
// stuck (matching the span heartbeat query), whereas a durable-table error
|
|
// DEFERS every candidate to the next sweep — we cannot tell backlog from
|
|
// orphan without it, and wrongly failing a live document is not recoverable
|
|
// by the user, while waiting one more interval is.
|
|
func (h *HousekeepingService) filterOutQueued(
|
|
ctx context.Context, candidates []types.Knowledge,
|
|
) (kept []types.Knowledge, skipped int) {
|
|
if len(candidates) == 0 {
|
|
return candidates, 0
|
|
}
|
|
|
|
ids := make([]string, 0, len(candidates))
|
|
for _, k := range candidates {
|
|
ids = append(ids, k.ID)
|
|
}
|
|
var durableIDs []string
|
|
if err := h.db.WithContext(ctx).
|
|
Model(&types.TaskPendingOp{}).
|
|
Where("task_type = ? AND scope = ? AND op = ? AND dedup_key IN ?",
|
|
wikiTaskType, wikiTaskScope, WikiOpIngest, ids).
|
|
Distinct("dedup_key").
|
|
Pluck("dedup_key", &durableIDs).Error; err != nil {
|
|
logger.Warnf(ctx,
|
|
"[Housekeeping] durable queue probe failed: %v (deferring %d candidate(s))",
|
|
err, len(candidates))
|
|
return candidates[:0], len(candidates)
|
|
}
|
|
durable := make(map[string]struct{}, len(durableIDs))
|
|
for _, id := range durableIDs {
|
|
durable[id] = struct{}{}
|
|
}
|
|
|
|
out := candidates[:0]
|
|
for _, k := range candidates {
|
|
if _, ok := durable[k.ID]; ok {
|
|
skipped++
|
|
continue
|
|
}
|
|
if h.inspector == nil {
|
|
out = append(out, k)
|
|
continue
|
|
}
|
|
queued, err := h.inspector.HasQueuedTasksForKnowledge(ctx, k.ID)
|
|
if err != nil {
|
|
logger.Warnf(ctx,
|
|
"[Housekeeping] queue probe failed for %s: %v (will fail safe and treat as stuck)", k.ID, err)
|
|
out = append(out, k)
|
|
continue
|
|
}
|
|
if queued {
|
|
skipped++
|
|
continue
|
|
}
|
|
out = append(out, k)
|
|
}
|
|
return out, skipped
|
|
}
|
|
|
|
// parseHeartbeatTime accepts the timestamp formats Postgres and SQLite
|
|
// emit for a TIMESTAMP column read back through MAX(). Returns false if
|
|
// none parse — the caller treats unparseable rows as "no heartbeat",
|
|
// which fails safe (the row gets recovered as stuck rather than
|
|
// silently preserved).
|
|
func parseHeartbeatTime(s string) (time.Time, bool) {
|
|
if s == "" {
|
|
return time.Time{}, false
|
|
}
|
|
for _, layout := range []string{
|
|
time.RFC3339Nano,
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05.999999999-07:00",
|
|
"2006-01-02 15:04:05.999999999",
|
|
"2006-01-02 15:04:05.999999",
|
|
"2006-01-02 15:04:05",
|
|
} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// staleThreshold returns how long a "processing" row may sit untouched
|
|
// before housekeeping treats it as orphaned. The floor is 1 hour so that a
|
|
// genuinely slow large-PDF parse cannot be killed mid-flight; the ceiling
|
|
// scales with the operator-configured DocumentProcessTimeout plus 10 minute
|
|
// buffer to absorb scheduling jitter.
|
|
func (h *HousekeepingService) staleThreshold() time.Duration {
|
|
base := 1 * time.Hour
|
|
if h.cfg != nil && h.cfg.KnowledgeBase != nil && h.cfg.KnowledgeBase.DocumentProcessTimeout > base {
|
|
base = h.cfg.KnowledgeBase.DocumentProcessTimeout
|
|
}
|
|
return base + 10*time.Minute
|
|
}
|
|
|
|
func housekeepingEnabled() bool {
|
|
// Default-on: missing/empty env enables the sweep. Operators must
|
|
// explicitly set "false" to opt out, matching the plan's commitment
|
|
// that no env change is required for the safety net to engage.
|
|
v := strings.TrimSpace(os.Getenv("WEKNORA_HOUSEKEEPING_ENABLED"))
|
|
if v == "" {
|
|
return true
|
|
}
|
|
switch strings.ToLower(v) {
|
|
case "0", "false", "off", "no":
|
|
return false
|
|
}
|
|
return true
|
|
}
|