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
524 lines
18 KiB
Go
524 lines
18 KiB
Go
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
|
|
}
|