WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// 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 err := preparePendingOp(op); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(op).Error
|
||||
}
|
||||
|
||||
func preparePendingOp(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 nil
|
||||
}
|
||||
|
||||
// EnqueueIfKnowledgeBaseActive prevents detached wiki cleanup from writing new
|
||||
// durable work after a KB was soft-deleted. On Postgres the share lock
|
||||
// serializes this check+insert transaction against the row update performed by
|
||||
// soft deletion: whichever operation acquires the row first determines the
|
||||
// order, and the deletion path's subsequent scope scrub removes any insert
|
||||
// that committed before it.
|
||||
func (r *taskPendingOpsRepository) EnqueueIfKnowledgeBaseActive(
|
||||
ctx context.Context,
|
||||
op *types.TaskPendingOp,
|
||||
) (bool, error) {
|
||||
if err := preparePendingOp(op); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if op.Scope != types.TaskScopeKnowledgeBase {
|
||||
return false, errors.New("task pending ops: guarded enqueue requires knowledge_base scope")
|
||||
}
|
||||
accepted := false
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
query := tx.Model(&types.KnowledgeBase{}).
|
||||
Select("id").
|
||||
Where("id = ? AND tenant_id = ?", op.ScopeID, op.TenantID)
|
||||
dialector := tx.Dialector
|
||||
if dialector.Name() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "SHARE"})
|
||||
}
|
||||
var kb types.KnowledgeBase
|
||||
if err := query.Take(&kb).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(op).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
accepted = true
|
||||
return nil
|
||||
})
|
||||
return accepted, err
|
||||
}
|
||||
|
||||
// SeedKnowledgeFinalizingWithPendingOp commits the finalizing counter and the
|
||||
// durable operation that owns one slot in the same transaction. This closes
|
||||
// the crash window where a knowledge row could enter finalizing before its
|
||||
// Wiki operation existed.
|
||||
func (r *taskPendingOpsRepository) SeedKnowledgeFinalizingWithPendingOp(
|
||||
ctx context.Context,
|
||||
knowledgeID string,
|
||||
expectedSubtasks int,
|
||||
op *types.TaskPendingOp,
|
||||
) (bool, error) {
|
||||
if knowledgeID == "" {
|
||||
return false, errors.New("task pending ops: knowledge_id is required")
|
||||
}
|
||||
if expectedSubtasks <= 0 {
|
||||
return false, errors.New("task pending ops: expected_subtasks must be positive")
|
||||
}
|
||||
if err := preparePendingOp(op); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if op.Scope != types.TaskScopeKnowledgeBase {
|
||||
return false, errors.New("task pending ops: finalizing seed requires knowledge_base scope")
|
||||
}
|
||||
|
||||
promoted := false
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
query := tx.Model(&types.KnowledgeBase{}).
|
||||
Select("id").
|
||||
Where("id = ? AND tenant_id = ?", op.ScopeID, op.TenantID)
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "SHARE"})
|
||||
}
|
||||
var kb types.KnowledgeBase
|
||||
if err := query.Take(&kb).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
res := tx.Model(&types.Knowledge{}).
|
||||
Where(
|
||||
"id = ? AND tenant_id = ? AND knowledge_base_id = ? AND parse_status = ?",
|
||||
knowledgeID, op.TenantID, op.ScopeID, types.ParseStatusProcessing,
|
||||
).
|
||||
Updates(map[string]interface{}{
|
||||
"parse_status": types.ParseStatusFinalizing,
|
||||
"pending_subtasks_count": expectedSubtasks,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Create(op).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
promoted = true
|
||||
return nil
|
||||
})
|
||||
return promoted, err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ClaimBatch atomically claims eligible rows for the tuple, grouped by
|
||||
// dedup_key. `limit` counts DISTINCT dedup_keys (i.e. documents), NOT rows:
|
||||
// ALL eligible rows sharing a chosen dedup_key are claimed together and
|
||||
// returned in the same batch. This is the invariant the concurrent wiki
|
||||
// consumers rely on — a document with multiple queued ops (e.g. an ingest
|
||||
// followed by a retract) must never be split across two concurrent batches,
|
||||
// otherwise each batch's per-batch last-write-wins dedup can't collapse the
|
||||
// pair and the two ops race (a stale ingest could resurrect a retracted doc).
|
||||
//
|
||||
// To uphold that invariant even for a sibling enqueued AFTER a batch already
|
||||
// claimed the key (e.g. a retract arriving while the ingest is still in
|
||||
// flight), a dedup_key that has ANY fresh claim (claimed_at >= staleBefore) is
|
||||
// skipped ENTIRELY — not just its already-claimed rows. The late sibling waits
|
||||
// for the holder to finish (which deletes the claimed rows, freeing the key) or
|
||||
// for the claim to go stale. This serializes same-document ops across
|
||||
// concurrent batches instead of letting them race on wall-clock completion.
|
||||
//
|
||||
// Eligibility = unclaimed (claimed_at IS NULL) OR stale claim
|
||||
// (claimed_at < staleBefore), AND the key has no fresh claim. The whole thing
|
||||
// runs in one transaction:
|
||||
//
|
||||
// - Postgres: we lock the ANCHOR row (earliest eligible id) of each
|
||||
// candidate dedup_key with FOR UPDATE SKIP LOCKED. Because the anchor
|
||||
// uniquely represents its key, SKIP LOCKED hands concurrent claimers
|
||||
// DISJOINT key sets — a key whose anchor is already locked by another
|
||||
// in-flight claim is skipped entirely rather than half-claimed. We then
|
||||
// stamp every eligible row of the chosen keys and read them back.
|
||||
// - Other dialects (SQLite, used by unit tests / Lite mode): writes are
|
||||
// serialized by the single-writer engine, so a plain grouped SELECT +
|
||||
// UPDATE is already race-free.
|
||||
//
|
||||
// Rows are claimed by explicit id (only the eligible ones), so a freshly
|
||||
// enqueued or still-in-flight sibling row of a chosen key is never handed
|
||||
// out twice.
|
||||
func (r *taskPendingOpsRepository) ClaimBatch(
|
||||
ctx context.Context,
|
||||
taskType, scope, scopeID string,
|
||||
limit int,
|
||||
staleBefore time.Time,
|
||||
) ([]*types.TaskPendingOp, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
now := time.Now()
|
||||
var claimed []*types.TaskPendingOp
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Pick up to `limit` distinct dedup_keys to claim, oldest first.
|
||||
// Keys with a fresh claim are excluded WHOLESALE so a late sibling
|
||||
// of an in-flight document never gets claimed on its own.
|
||||
var keys []string
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
// Lock the anchor (earliest eligible) row of each key with SKIP
|
||||
// LOCKED so concurrent claimers get disjoint KEY sets, then map
|
||||
// the locked anchors back to their dedup_keys. The NOT IN subquery
|
||||
// drops any key that still has a fresh (non-stale) claim.
|
||||
const anchorSQL = `
|
||||
SELECT dedup_key FROM task_pending_ops
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY id) AS rn
|
||||
FROM task_pending_ops
|
||||
WHERE task_type = ? AND scope = ? AND scope_id = ?
|
||||
AND (claimed_at IS NULL OR claimed_at < ?)
|
||||
AND dedup_key NOT IN (
|
||||
SELECT dedup_key FROM task_pending_ops
|
||||
WHERE task_type = ? AND scope = ? AND scope_id = ?
|
||||
AND claimed_at IS NOT NULL AND claimed_at >= ?
|
||||
)
|
||||
) anchors WHERE anchors.rn = 1
|
||||
)
|
||||
ORDER BY id ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE SKIP LOCKED`
|
||||
if err := tx.Raw(anchorSQL,
|
||||
taskType, scope, scopeID, staleBefore,
|
||||
taskType, scope, scopeID, staleBefore,
|
||||
limit).
|
||||
Scan(&keys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
freshKeys := tx.Model(&types.TaskPendingOp{}).
|
||||
Select("dedup_key").
|
||||
Where("task_type = ? AND scope = ? AND scope_id = ?", taskType, scope, scopeID).
|
||||
Where("claimed_at IS NOT NULL AND claimed_at >= ?", staleBefore)
|
||||
if err := tx.Model(&types.TaskPendingOp{}).
|
||||
Where("task_type = ? AND scope = ? AND scope_id = ?", taskType, scope, scopeID).
|
||||
Where("(claimed_at IS NULL OR claimed_at < ?)", staleBefore).
|
||||
Where("dedup_key NOT IN (?)", freshKeys).
|
||||
Group("dedup_key").
|
||||
Order("MIN(id) ASC").
|
||||
Limit(limit).
|
||||
Pluck("dedup_key", &keys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Resolve the exact eligible rows for the chosen keys and claim
|
||||
// them by id. Claiming by id (not by "dedup_key IN keys") means a
|
||||
// sibling row that is still in flight elsewhere (claimed & fresh)
|
||||
// is left untouched and never returned to this batch.
|
||||
var ids []int64
|
||||
if err := tx.Model(&types.TaskPendingOp{}).
|
||||
Where("task_type = ? AND scope = ? AND scope_id = ?", taskType, scope, scopeID).
|
||||
Where("dedup_key IN ?", keys).
|
||||
Where("(claimed_at IS NULL OR claimed_at < ?)", staleBefore).
|
||||
Order("id ASC").
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Model(&types.TaskPendingOp{}).
|
||||
Where("id IN ?", ids).
|
||||
Update("claimed_at", now).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id IN ?", ids).Order("id ASC").Find(&claimed).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
// ReleaseByIDs clears claimed_at for the given rows, returning them to the
|
||||
// unclaimed pool. Empty input is a no-op. Setting claimed_at back to NULL
|
||||
// on a row that was never claimed is harmless.
|
||||
func (r *taskPendingOpsRepository) ReleaseByIDs(ctx context.Context, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&types.TaskPendingOp{}).
|
||||
Where("id IN ?", ids).
|
||||
Update("claimed_at", nil).Error
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DeleteByScope removes every pending operation owned by a scope, regardless
|
||||
// of task type. Both scope fields are required so a malformed lifecycle call
|
||||
// can never turn into an unbounded queue deletion.
|
||||
func (r *taskPendingOpsRepository) DeleteByScope(ctx context.Context, scope, scopeID string) error {
|
||||
if scope == "" || scopeID == "" {
|
||||
return errors.New("task pending ops: scope and scope_id are required")
|
||||
}
|
||||
return r.db.WithContext(ctx).
|
||||
Where("scope = ? AND scope_id = ?", scope, scopeID).
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user