04e8105728
The wiki ingest post-process pipeline OOM'd and ran for hours on KBs
with ~40k documents. The dominant tail was a per-batch ListAllPages
that pulled every page (multi-MB content blobs) into Go memory, plus a
24h-TTL Redis pending list whose data could be evicted before a long
serialized backlog drained. dedup did O(P × N) Jaccard scoring in Go
on every batch. Lint loaded the full graph. The index page kept the
entire wiki directory in its content column and rewrote the TOAST
chunk on every ingest. None of these survive at 4w docs.
This change reworks the write path end to end and pulls the durable-
queue + dead-letter primitives out of wiki and into shared
infrastructure that every asynq task type now benefits from.
- migration 000041: task_pending_ops + task_dead_letters tables, plus
three GIN indexes on wiki_pages (source_refs jsonb_path_ops,
source_refs text fallback, lower(title) trgm).
- TaskPendingOpsRepository + TaskDeadLetterRepository in
internal/application/repository/task_queue.go: cursor-paginated
list, atomic IncrFailCount via UPDATE…RETURNING, dedup-key scoped
delete that refuses empty keys so a buggy caller can't wipe a KB's
queue.
- internal/middleware/asynqdl: writes a task_dead_letters row when an
asynq task exhausts its retry budget. Payload-agnostic — a small
probe struct extracts TenantID + scope hints across every existing
payload type, so summary:generation / image:multimodal /
faq_import / kb:clone / etc. all dead-letter without per-handler
code. Best-effort: an insert failure never masks the underlying
task error. Installed in router/task.go before the langfuse mw so
it sees raw errors.
- Pending queue moved Redis → PG (no TTL, restart-durable). Redis
keeps just the active-batch lock and the delete tombstone.
- In-batch retry budget (wikiMaxFailRetries=5) tracked via
pendingRepo.IncrFailCount; over the cap the row is moved to
task_dead_letters with task_type=wiki:ingest, related_id=knowledge
id. Asynq retries (10) are handled by the new middleware.
- Removed the per-batch ListAllPages. WikiBatchContext now carries
lazy fetcher closures (SlugTitleMany, SummaryByKnowledgeID) with
mutex-protected caches; reduce reaches for titles / summaries on
demand instead of pre-loading the whole KB.
- getExistingPageSlugsForKnowledge now uses ListSlugsBySourceRef,
which the new GIN index on source_refs serves as a Bitmap Index
Scan instead of a sequential text LIKE.
- Dedup pre-filter uses pg_trgm via FindSimilarPages
(idx_wiki_pages_title_trgm). For each new entity/concept (and each
of its aliases) we ask the DB for the top-K trigram-similar
existing pages and union the results — bounded prompt size, no Go-
side O(P × M) loop. Small KBs (≤25 entities) bypass the pre-filter.
- cleanDeadLinks / injectCrossLinks are scoped to the batch's
affected slugs (a few dozen) instead of every page in the KB. Both
use the new lite ListBySlugs / ExistsSlugs repo methods so they
pull only slug + title + outlinks, not full content.
- Slug fuzzy resolve (slug_fuzzy.go): when the LLM emits
[[bad-slug|display]] the cleanup path now tries display-text
reverse lookup → hyphen/case normalized equality → char-bigram
Jaccard ≥ 0.8 before stripping. Recovers the common pinyin-word-
break drift case ("shang-hai-tower" vs "shanghai-tower") in place
instead of replacing the link with plain text.
- rebuildIndexPage uses ListByTypeRecent(200) for the first-time
intro and drops the full DocumentSummaries blob from the
incremental update prompt, so its context stays bounded regardless
of KB size.
- Concurrency tunables surfaced in WikiConfig: IngestBatchSize /
IngestMapParallel / IngestReduceParallel, with sensible defaults
via OrDefault helpers. scheduleFollowUp drops to ProcessIn(0) so
follow-ups don't waste asynq retry slots bouncing on the active
lock.
- RunLint walks pages via the new ListPagesCursor in 200-page
windows and computes the live-slug set with a one-column
ListAllSlugs Pluck instead of a Limit:0 GetGraph that materialized
every node + edge. Memory is now bounded; 40k pages walks in
constant ~4MB.
- 14 GORM tests for both repos against an in-memory SQLite mirror of
the production DDL (task_queue_test.go).
- 6 tests for the dead-letter middleware covering retry budget
detection, payload-agnostic scope inference, error truncation, and
repo-failure isolation.
- 7 tests for the slug fuzzy resolve helper covering the three
resolution stages, display-text priority over normalized equality,
bigram fallback acceptance, and rejection of unrelated slugs.
- Existing wiki_ingest / wiki_lint / wiki_page tests updated to the
new fetcher / cursor APIs.
253 lines
7.9 KiB
Go
253 lines
7.9 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// taskPendingOpsRepository implements interfaces.TaskPendingOpsRepository.
|
|
type taskPendingOpsRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewTaskPendingOpsRepository constructs a GORM-backed implementation.
|
|
func NewTaskPendingOpsRepository(db *gorm.DB) interfaces.TaskPendingOpsRepository {
|
|
return &taskPendingOpsRepository{db: db}
|
|
}
|
|
|
|
// Enqueue inserts a single op. Callers must populate TenantID/TaskType/
|
|
// Scope/ScopeID/Op (Payload optional). ID, FailCount default to zero;
|
|
// EnqueuedAt is filled with the DB-side default if left zero.
|
|
func (r *taskPendingOpsRepository) Enqueue(ctx context.Context, op *types.TaskPendingOp) error {
|
|
if op == nil {
|
|
return errors.New("task pending ops: nil op")
|
|
}
|
|
if op.TaskType == "" || op.Scope == "" || op.ScopeID == "" {
|
|
return errors.New("task pending ops: task_type, scope, scope_id are required")
|
|
}
|
|
if op.Op == "" {
|
|
return errors.New("task pending ops: op is required")
|
|
}
|
|
if len(op.Payload) == 0 {
|
|
// Make sure the JSONB column never sees NULL — the schema sets a
|
|
// default but explicit "{}" keeps the row uniform regardless of
|
|
// driver-level default handling.
|
|
op.Payload = []byte("{}")
|
|
}
|
|
return r.db.WithContext(ctx).Create(op).Error
|
|
}
|
|
|
|
// PeekBatch returns up to `limit` rows for the (task_type, scope, scope_id)
|
|
// tuple ordered by id ASC. Rows are not removed; callers must
|
|
// DeleteByIDs once they have been consumed (or IncrFailCount and leave
|
|
// them for the next pass). `limit` <= 0 falls back to 1; we clamp the
|
|
// upper bound generously so callers can pull large windows when they
|
|
// know the consumer can handle them.
|
|
func (r *taskPendingOpsRepository) PeekBatch(
|
|
ctx context.Context,
|
|
taskType, scope, scopeID string,
|
|
limit int,
|
|
) ([]*types.TaskPendingOp, error) {
|
|
if limit <= 0 {
|
|
limit = 1
|
|
}
|
|
if limit > 1000 {
|
|
limit = 1000
|
|
}
|
|
var ops []*types.TaskPendingOp
|
|
if err := r.db.WithContext(ctx).
|
|
Where("task_type = ? AND scope = ? AND scope_id = ?", taskType, scope, scopeID).
|
|
Order("id ASC").
|
|
Limit(limit).
|
|
Find(&ops).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return ops, nil
|
|
}
|
|
|
|
// DeleteByIDs removes the given rows in one statement. Empty input is a
|
|
// no-op so the caller can invoke unconditionally at the end of a batch.
|
|
func (r *taskPendingOpsRepository) DeleteByIDs(ctx context.Context, ids []int64) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
return r.db.WithContext(ctx).
|
|
Where("id IN ?", ids).
|
|
Delete(&types.TaskPendingOp{}).Error
|
|
}
|
|
|
|
// IncrFailCount atomically bumps fail_count for one row and returns the
|
|
// new value. We use UPDATE ... RETURNING so the read+write happens in
|
|
// one round trip and races between concurrent IncrFailCount callers
|
|
// resolve to monotonic counts.
|
|
//
|
|
// A missing row returns (0, nil): the caller's ID may have been removed
|
|
// by a concurrent DeleteByIDs (e.g. dead-letter path), which is benign.
|
|
func (r *taskPendingOpsRepository) IncrFailCount(ctx context.Context, id int64) (int, error) {
|
|
var newCount int
|
|
err := r.db.WithContext(ctx).Raw(
|
|
`UPDATE task_pending_ops SET fail_count = fail_count + 1 WHERE id = ? RETURNING fail_count`,
|
|
id,
|
|
).Scan(&newCount).Error
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return newCount, nil
|
|
}
|
|
|
|
// PendingCount returns how many rows are currently queued for the
|
|
// tuple. Covered by idx_task_pending_ops_scope.
|
|
func (r *taskPendingOpsRepository) PendingCount(
|
|
ctx context.Context,
|
|
taskType, scope, scopeID string,
|
|
) (int64, error) {
|
|
var n int64
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&types.TaskPendingOp{}).
|
|
Where("task_type = ? AND scope = ? AND scope_id = ?", taskType, scope, scopeID).
|
|
Count(&n).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// DeleteByDedupKey drops rows in the tuple whose dedup_key matches.
|
|
// If `op` is non-empty, only rows with the matching op are dropped;
|
|
// otherwise every matching row is removed. Empty dedup_key is rejected
|
|
// to prevent accidentally wiping the entire queue for a KB.
|
|
//
|
|
// Used by:
|
|
// - Wiki delete path: scrub queued WikiOpIngest entries for a
|
|
// knowledge that is being deleted, while preserving WikiOpRetract
|
|
// so the cleanup can still unlink pages.
|
|
// - Wiki reparse path: same scrub of pending ingests so the new
|
|
// parse can repopulate cleanly.
|
|
func (r *taskPendingOpsRepository) DeleteByDedupKey(
|
|
ctx context.Context,
|
|
taskType, scope, scopeID, dedupKey, op string,
|
|
) error {
|
|
if dedupKey == "" {
|
|
return fmt.Errorf("task pending ops: empty dedup_key in DeleteByDedupKey")
|
|
}
|
|
q := r.db.WithContext(ctx).
|
|
Where("task_type = ? AND scope = ? AND scope_id = ? AND dedup_key = ?",
|
|
taskType, scope, scopeID, dedupKey)
|
|
if op != "" {
|
|
q = q.Where("op = ?", op)
|
|
}
|
|
return q.Delete(&types.TaskPendingOp{}).Error
|
|
}
|
|
|
|
// taskDeadLetterRepository implements interfaces.TaskDeadLetterRepository.
|
|
type taskDeadLetterRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewTaskDeadLetterRepository constructs a GORM-backed implementation.
|
|
func NewTaskDeadLetterRepository(db *gorm.DB) interfaces.TaskDeadLetterRepository {
|
|
return &taskDeadLetterRepository{db: db}
|
|
}
|
|
|
|
// Insert records one dead letter. Best-effort caller: the asynq
|
|
// middleware swallows the error so a failed insert never masks the
|
|
// underlying task error.
|
|
func (r *taskDeadLetterRepository) Insert(ctx context.Context, dl *types.TaskDeadLetter) error {
|
|
if dl == nil {
|
|
return errors.New("task dead letters: nil entry")
|
|
}
|
|
if dl.TaskType == "" {
|
|
return errors.New("task dead letters: task_type is required")
|
|
}
|
|
if dl.Scope == "" {
|
|
dl.Scope = types.TaskScopeUnknown
|
|
}
|
|
if len(dl.Payload) == 0 {
|
|
dl.Payload = []byte("{}")
|
|
}
|
|
return r.db.WithContext(ctx).Create(dl).Error
|
|
}
|
|
|
|
// ListByScope returns dead letters for (scope, scope_id) newest-first
|
|
// with a stringified id cursor. `limit` is clamped to [1, 200]. Empty
|
|
// nextCursor signals the tail.
|
|
func (r *taskDeadLetterRepository) ListByScope(
|
|
ctx context.Context,
|
|
scope, scopeID, cursor string,
|
|
limit int,
|
|
) ([]*types.TaskDeadLetter, string, error) {
|
|
if scope == "" || scopeID == "" {
|
|
return nil, "", errors.New("task dead letters: scope and scope_id are required")
|
|
}
|
|
return r.list(ctx, cursor, limit, func(q *gorm.DB) *gorm.DB {
|
|
return q.Where("scope = ? AND scope_id = ?", scope, scopeID)
|
|
})
|
|
}
|
|
|
|
// ListByTaskType returns dead letters for the given task_type
|
|
// newest-first with a stringified id cursor. Same clamping rules.
|
|
func (r *taskDeadLetterRepository) ListByTaskType(
|
|
ctx context.Context,
|
|
taskType, cursor string,
|
|
limit int,
|
|
) ([]*types.TaskDeadLetter, string, error) {
|
|
if taskType == "" {
|
|
return nil, "", errors.New("task dead letters: task_type is required")
|
|
}
|
|
return r.list(ctx, cursor, limit, func(q *gorm.DB) *gorm.DB {
|
|
return q.Where("task_type = ?", taskType)
|
|
})
|
|
}
|
|
|
|
// list is the shared cursor pagination implementation, parametrized by
|
|
// the caller-supplied filter. Mirrors wikiLogEntryRepository.List.
|
|
func (r *taskDeadLetterRepository) list(
|
|
ctx context.Context,
|
|
cursor string,
|
|
limit int,
|
|
filter func(*gorm.DB) *gorm.DB,
|
|
) ([]*types.TaskDeadLetter, string, error) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
|
|
q := r.db.WithContext(ctx).Order("id DESC").Limit(limit)
|
|
q = filter(q)
|
|
|
|
if cursor != "" {
|
|
cursorID, err := strconv.ParseInt(cursor, 10, 64)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("invalid cursor %q: %w", cursor, err)
|
|
}
|
|
q = q.Where("id < ?", cursorID)
|
|
}
|
|
|
|
var rows []*types.TaskDeadLetter
|
|
if err := q.Find(&rows).Error; err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
nextCursor := ""
|
|
if len(rows) == limit {
|
|
nextCursor = strconv.FormatInt(rows[len(rows)-1].ID, 10)
|
|
}
|
|
return rows, nextCursor, nil
|
|
}
|
|
|
|
// DeleteByID drops a single dead letter row. Returns nil even if the
|
|
// row is already gone — operators issuing concurrent deletes shouldn't
|
|
// see spurious errors.
|
|
func (r *taskDeadLetterRepository) DeleteByID(ctx context.Context, id int64) error {
|
|
return r.db.WithContext(ctx).
|
|
Where("id = ?", id).
|
|
Delete(&types.TaskDeadLetter{}).Error
|
|
}
|