838 lines
33 KiB
Go
838 lines
33 KiB
Go
package repository
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/Tencent/WeKnora/internal/types"
|
||
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||
|
|
"github.com/stretchr/testify/assert"
|
||
|
|
"github.com/stretchr/testify/require"
|
||
|
|
"gorm.io/driver/sqlite"
|
||
|
|
"gorm.io/gorm"
|
||
|
|
)
|
||
|
|
|
||
|
|
// taskPendingOpsTestDDL mirrors the production schema in
|
||
|
|
// migrations/versioned/000041_task_queue_and_wiki_indexes.up.sql but uses
|
||
|
|
// SQLite-compatible types. INTEGER PRIMARY KEY AUTOINCREMENT preserves
|
||
|
|
// the monotonically-increasing ID semantics PeekBatch/cursor pagination
|
||
|
|
// rely on. JSONB → TEXT is fine since GORM round-trips json.RawMessage
|
||
|
|
// as bytes either way.
|
||
|
|
const taskPendingOpsTestDDL = `
|
||
|
|
CREATE TABLE IF NOT EXISTS task_pending_ops (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
tenant_id INTEGER NOT NULL,
|
||
|
|
task_type VARCHAR(64) NOT NULL,
|
||
|
|
scope VARCHAR(32) NOT NULL,
|
||
|
|
scope_id VARCHAR(64) NOT NULL,
|
||
|
|
op VARCHAR(32) NOT NULL,
|
||
|
|
dedup_key VARCHAR(128) NOT NULL DEFAULT '',
|
||
|
|
payload TEXT NOT NULL DEFAULT '{}',
|
||
|
|
fail_count INTEGER NOT NULL DEFAULT 0,
|
||
|
|
enqueued_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
|
|
claimed_at DATETIME
|
||
|
|
);
|
||
|
|
`
|
||
|
|
|
||
|
|
const taskDeadLettersTestDDL = `
|
||
|
|
CREATE TABLE IF NOT EXISTS task_dead_letters (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
tenant_id INTEGER NOT NULL,
|
||
|
|
task_type VARCHAR(64) NOT NULL,
|
||
|
|
scope VARCHAR(32) NOT NULL,
|
||
|
|
scope_id VARCHAR(64) NOT NULL,
|
||
|
|
related_id VARCHAR(64) NOT NULL DEFAULT '',
|
||
|
|
payload TEXT NOT NULL,
|
||
|
|
last_error TEXT NOT NULL DEFAULT '',
|
||
|
|
fail_count INTEGER NOT NULL,
|
||
|
|
failed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
|
|
);
|
||
|
|
`
|
||
|
|
|
||
|
|
const taskQueueKnowledgeBaseTestDDL = `
|
||
|
|
CREATE TABLE IF NOT EXISTS knowledge_bases (
|
||
|
|
id VARCHAR(64) PRIMARY KEY,
|
||
|
|
tenant_id INTEGER NOT NULL,
|
||
|
|
deleted_at DATETIME
|
||
|
|
);
|
||
|
|
`
|
||
|
|
|
||
|
|
const taskQueueKnowledgeTestDDL = `
|
||
|
|
CREATE TABLE IF NOT EXISTS knowledges (
|
||
|
|
id VARCHAR(64) PRIMARY KEY,
|
||
|
|
tenant_id INTEGER NOT NULL,
|
||
|
|
knowledge_base_id VARCHAR(64) NOT NULL,
|
||
|
|
parse_status VARCHAR(32) NOT NULL,
|
||
|
|
pending_subtasks_count INTEGER NOT NULL DEFAULT 0,
|
||
|
|
updated_at DATETIME,
|
||
|
|
deleted_at DATETIME
|
||
|
|
);
|
||
|
|
`
|
||
|
|
|
||
|
|
func setupTaskQueueTestDB(t *testing.T) *gorm.DB {
|
||
|
|
t.Helper()
|
||
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.NoError(t, db.Exec(taskPendingOpsTestDDL).Error)
|
||
|
|
require.NoError(t, db.Exec(taskDeadLettersTestDDL).Error)
|
||
|
|
return db
|
||
|
|
}
|
||
|
|
|
||
|
|
func makePendingOp(taskType, scope, scopeID, op, dedup string, payload []byte) *types.TaskPendingOp {
|
||
|
|
return &types.TaskPendingOp{
|
||
|
|
TenantID: 1,
|
||
|
|
TaskType: taskType,
|
||
|
|
Scope: scope,
|
||
|
|
ScopeID: scopeID,
|
||
|
|
Op: op,
|
||
|
|
DedupKey: dedup,
|
||
|
|
Payload: payload,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func setupFinalizingPendingOpTest(t *testing.T) (*gorm.DB, interfaces.TaskPendingOpsFinalizingSeeder) {
|
||
|
|
t.Helper()
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
seeder, ok := repo.(interfaces.TaskPendingOpsFinalizingSeeder)
|
||
|
|
require.True(t, ok, "task pending repository must support atomic finalizing handoff")
|
||
|
|
require.NoError(t, db.Exec(taskQueueKnowledgeBaseTestDDL).Error)
|
||
|
|
require.NoError(t, db.Exec(taskQueueKnowledgeTestDDL).Error)
|
||
|
|
require.NoError(t, db.Exec(
|
||
|
|
`INSERT INTO knowledge_bases (id, tenant_id) VALUES ('kb-1', 1)`,
|
||
|
|
).Error)
|
||
|
|
require.NoError(t, db.Exec(
|
||
|
|
`INSERT INTO knowledges (id, tenant_id, knowledge_base_id, parse_status) VALUES ('knowledge-1', 1, 'kb-1', ?)`,
|
||
|
|
types.ParseStatusProcessing,
|
||
|
|
).Error)
|
||
|
|
return db, seeder
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTaskPendingOps_SeedKnowledgeFinalizingWithPendingOpCommitsTogether(t *testing.T) {
|
||
|
|
db, seeder := setupFinalizingPendingOpTest(t)
|
||
|
|
op := makePendingOp(types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb-1", "ingest", "knowledge-1", []byte(`{}`))
|
||
|
|
|
||
|
|
promoted, err := seeder.SeedKnowledgeFinalizingWithPendingOp(
|
||
|
|
context.Background(), "knowledge-1", 3, op,
|
||
|
|
)
|
||
|
|
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.True(t, promoted)
|
||
|
|
var knowledge types.Knowledge
|
||
|
|
require.NoError(t, db.Select("parse_status", "pending_subtasks_count").First(&knowledge, "id = ?", "knowledge-1").Error)
|
||
|
|
assert.Equal(t, types.ParseStatusFinalizing, knowledge.ParseStatus)
|
||
|
|
assert.Equal(t, 3, knowledge.PendingSubtasksCount)
|
||
|
|
var count int64
|
||
|
|
require.NoError(t, db.Model(&types.TaskPendingOp{}).Where("dedup_key = ?", "knowledge-1").Count(&count).Error)
|
||
|
|
assert.Equal(t, int64(1), count)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTaskPendingOps_SeedKnowledgeFinalizingRollsBackWhenPendingOpInsertFails(t *testing.T) {
|
||
|
|
db, seeder := setupFinalizingPendingOpTest(t)
|
||
|
|
require.NoError(t, db.Exec(`DROP TABLE task_pending_ops`).Error)
|
||
|
|
op := makePendingOp(types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb-1", "ingest", "knowledge-1", []byte(`{}`))
|
||
|
|
|
||
|
|
promoted, err := seeder.SeedKnowledgeFinalizingWithPendingOp(
|
||
|
|
context.Background(), "knowledge-1", 3, op,
|
||
|
|
)
|
||
|
|
|
||
|
|
require.Error(t, err)
|
||
|
|
assert.False(t, promoted)
|
||
|
|
var knowledge types.Knowledge
|
||
|
|
require.NoError(t, db.Select("parse_status", "pending_subtasks_count").First(&knowledge, "id = ?", "knowledge-1").Error)
|
||
|
|
assert.Equal(t, types.ParseStatusProcessing, knowledge.ParseStatus)
|
||
|
|
assert.Zero(t, knowledge.PendingSubtasksCount)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTaskPendingOps_SeedKnowledgeFinalizingSkipsNonProcessingKnowledge(t *testing.T) {
|
||
|
|
db, seeder := setupFinalizingPendingOpTest(t)
|
||
|
|
require.NoError(t, db.Model(&types.Knowledge{}).
|
||
|
|
Where("id = ?", "knowledge-1").
|
||
|
|
Update("parse_status", types.ParseStatusCancelled).Error)
|
||
|
|
op := makePendingOp(types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb-1", "ingest", "knowledge-1", []byte(`{}`))
|
||
|
|
|
||
|
|
promoted, err := seeder.SeedKnowledgeFinalizingWithPendingOp(
|
||
|
|
context.Background(), "knowledge-1", 3, op,
|
||
|
|
)
|
||
|
|
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.False(t, promoted)
|
||
|
|
var count int64
|
||
|
|
require.NoError(t, db.Model(&types.TaskPendingOp{}).Count(&count).Error)
|
||
|
|
assert.Zero(t, count)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTaskPendingOps_SeedKnowledgeFinalizingSkipsDeletedKnowledgeBase(t *testing.T) {
|
||
|
|
db, seeder := setupFinalizingPendingOpTest(t)
|
||
|
|
require.NoError(t, db.Exec(
|
||
|
|
`UPDATE knowledge_bases SET deleted_at = ? WHERE id = ?`, time.Now(), "kb-1",
|
||
|
|
).Error)
|
||
|
|
op := makePendingOp(types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb-1", "ingest", "knowledge-1", []byte(`{}`))
|
||
|
|
|
||
|
|
promoted, err := seeder.SeedKnowledgeFinalizingWithPendingOp(
|
||
|
|
context.Background(), "knowledge-1", 3, op,
|
||
|
|
)
|
||
|
|
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.False(t, promoted)
|
||
|
|
var knowledge types.Knowledge
|
||
|
|
require.NoError(t, db.Select("parse_status", "pending_subtasks_count").First(&knowledge, "id = ?", "knowledge-1").Error)
|
||
|
|
assert.Equal(t, types.ParseStatusProcessing, knowledge.ParseStatus)
|
||
|
|
assert.Zero(t, knowledge.PendingSubtasksCount)
|
||
|
|
var count int64
|
||
|
|
require.NoError(t, db.Model(&types.TaskPendingOp{}).Count(&count).Error)
|
||
|
|
assert.Zero(t, count)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------- TaskPendingOpsRepository ----------------
|
||
|
|
|
||
|
|
// TestTaskPendingOps_Enqueue_AssignsIDAndDefaults verifies a freshly
|
||
|
|
// inserted op gets a positive ID and the empty payload becomes "{}"
|
||
|
|
// rather than NULL/empty.
|
||
|
|
func TestTaskPendingOps_Enqueue_AssignsIDAndDefaults(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
op := makePendingOp("wiki:ingest", "knowledge_base", "kb-1", "ingest", "k-1", nil)
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, op))
|
||
|
|
assert.NotZero(t, op.ID)
|
||
|
|
assert.Equal(t, json.RawMessage("{}"), op.Payload, "nil payload should default to {}")
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_Enqueue_RejectsMissingFields covers the validation
|
||
|
|
// layer: every required field must be set, otherwise the call returns an
|
||
|
|
// error WITHOUT touching the DB.
|
||
|
|
func TestTaskPendingOps_Enqueue_RejectsMissingFields(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
cases := []struct {
|
||
|
|
name string
|
||
|
|
op *types.TaskPendingOp
|
||
|
|
}{
|
||
|
|
{"nil op", nil},
|
||
|
|
{"missing task_type", makePendingOp("", "knowledge_base", "kb", "ingest", "", nil)},
|
||
|
|
{"missing scope", makePendingOp("t", "", "kb", "ingest", "", nil)},
|
||
|
|
{"missing scope_id", makePendingOp("t", "s", "", "ingest", "", nil)},
|
||
|
|
{"missing op", makePendingOp("t", "s", "id", "", "", nil)},
|
||
|
|
}
|
||
|
|
for _, c := range cases {
|
||
|
|
t.Run(c.name, func(t *testing.T) {
|
||
|
|
err := repo.Enqueue(ctx, c.op)
|
||
|
|
assert.Error(t, err)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
var n int64
|
||
|
|
db.Table("task_pending_ops").Count(&n)
|
||
|
|
assert.Equal(t, int64(0), n)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_PeekBatch_ScopedAndOrdered verifies PeekBatch only
|
||
|
|
// returns rows for the matching tuple, in id ASC order, and respects
|
||
|
|
// the limit.
|
||
|
|
func TestTaskPendingOps_PeekBatch_ScopedAndOrdered(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
// Three ops in kb-A, two in kb-B, one in different task_type.
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb-A", "ingest", "k1", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb-A", "retract", "k2", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb-A", "ingest", "k3", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb-B", "ingest", "k4", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb-B", "ingest", "k5", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("summary:gen", "knowledge_base", "kb-A", "ingest", "k6", nil)))
|
||
|
|
|
||
|
|
// Peek up to 10 from kb-A — should see exactly 3, in insertion order.
|
||
|
|
got, err := repo.PeekBatch(ctx, "wiki:ingest", "knowledge_base", "kb-A", 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, got, 3)
|
||
|
|
assert.Equal(t, "k1", got[0].DedupKey)
|
||
|
|
assert.Equal(t, "k2", got[1].DedupKey)
|
||
|
|
assert.Equal(t, "k3", got[2].DedupKey)
|
||
|
|
assert.True(t, got[0].ID < got[1].ID && got[1].ID < got[2].ID, "ids should be ascending")
|
||
|
|
|
||
|
|
// Limit caps result size.
|
||
|
|
got, err = repo.PeekBatch(ctx, "wiki:ingest", "knowledge_base", "kb-A", 2)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Len(t, got, 2)
|
||
|
|
|
||
|
|
// Different task_type isolated.
|
||
|
|
got, err = repo.PeekBatch(ctx, "summary:gen", "knowledge_base", "kb-A", 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, got, 1)
|
||
|
|
assert.Equal(t, "k6", got[0].DedupKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_DeleteByIDs_RemovesOnlyTargets verifies the
|
||
|
|
// delete-after-consume path. Empty input must be a no-op so the consumer
|
||
|
|
// can call it unconditionally.
|
||
|
|
func TestTaskPendingOps_DeleteByIDs_RemovesOnlyTargets(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
a := makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "a", nil)
|
||
|
|
b := makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "b", nil)
|
||
|
|
c := makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "c", nil)
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, a))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, b))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, c))
|
||
|
|
|
||
|
|
// No-op: empty slice.
|
||
|
|
require.NoError(t, repo.DeleteByIDs(ctx, nil))
|
||
|
|
require.NoError(t, repo.DeleteByIDs(ctx, []int64{}))
|
||
|
|
|
||
|
|
// Delete a + c, keep b.
|
||
|
|
require.NoError(t, repo.DeleteByIDs(ctx, []int64{a.ID, c.ID}))
|
||
|
|
|
||
|
|
got, err := repo.PeekBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, got, 1)
|
||
|
|
assert.Equal(t, "b", got[0].DedupKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTaskPendingOps_DeleteByScope_RemovesAllTaskTypesAndIsolatesScopes(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
cleaner, ok := repo.(interfaces.TaskPendingOpsScopeCleaner)
|
||
|
|
require.True(t, ok)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
// The deleted KB can have durable work in more than one wiki queue.
|
||
|
|
require.NoError(t, repo.Enqueue(ctx,
|
||
|
|
makePendingOp(types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb-delete", "ingest", "k1", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx,
|
||
|
|
makePendingOp(types.TypeWikiFinalize, types.TaskScopeKnowledgeBase, "kb-delete", "finalize", "k2", nil)))
|
||
|
|
|
||
|
|
// Rows in another KB or another scope must survive even when their IDs or
|
||
|
|
// task types overlap with the deleted KB.
|
||
|
|
require.NoError(t, repo.Enqueue(ctx,
|
||
|
|
makePendingOp(types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb-keep", "ingest", "k3", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx,
|
||
|
|
makePendingOp(types.TypeWikiFinalize, types.TaskScopeKnowledge, "kb-delete", "finalize", "k4", nil)))
|
||
|
|
|
||
|
|
require.NoError(t, cleaner.DeleteByScope(ctx, types.TaskScopeKnowledgeBase, "kb-delete"))
|
||
|
|
|
||
|
|
var remaining []*types.TaskPendingOp
|
||
|
|
require.NoError(t, db.Order("id ASC").Find(&remaining).Error)
|
||
|
|
require.Len(t, remaining, 2)
|
||
|
|
assert.Equal(t, "kb-keep", remaining[0].ScopeID)
|
||
|
|
assert.Equal(t, types.TaskScopeKnowledge, remaining[1].Scope)
|
||
|
|
assert.Equal(t, "kb-delete", remaining[1].ScopeID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTaskPendingOps_DeleteByScope_RejectsMissingScope(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
cleaner, ok := repo.(interfaces.TaskPendingOpsScopeCleaner)
|
||
|
|
require.True(t, ok)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
require.NoError(t, repo.Enqueue(ctx,
|
||
|
|
makePendingOp(types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb", "ingest", "k1", nil)))
|
||
|
|
|
||
|
|
assert.Error(t, cleaner.DeleteByScope(ctx, "", "kb"))
|
||
|
|
assert.Error(t, cleaner.DeleteByScope(ctx, types.TaskScopeKnowledgeBase, ""))
|
||
|
|
|
||
|
|
var count int64
|
||
|
|
require.NoError(t, db.Model(&types.TaskPendingOp{}).Count(&count).Error)
|
||
|
|
assert.Equal(t, int64(1), count)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTaskPendingOps_EnqueueIfKnowledgeBaseActive(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
require.NoError(t, db.Exec(`CREATE TABLE knowledge_bases (
|
||
|
|
id VARCHAR(64) PRIMARY KEY,
|
||
|
|
tenant_id INTEGER NOT NULL,
|
||
|
|
deleted_at DATETIME
|
||
|
|
)`).Error)
|
||
|
|
require.NoError(t, db.Exec(
|
||
|
|
"INSERT INTO knowledge_bases (id, tenant_id, deleted_at) VALUES (?, ?, NULL), (?, ?, ?)",
|
||
|
|
"kb-active", 1, "kb-deleted", 1, time.Now(),
|
||
|
|
).Error)
|
||
|
|
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
guard, ok := repo.(interfaces.TaskPendingOpsKnowledgeBaseGuard)
|
||
|
|
require.True(t, ok)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
accepted, err := guard.EnqueueIfKnowledgeBaseActive(ctx,
|
||
|
|
makePendingOp(types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb-active", "ingest", "active", nil))
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.True(t, accepted)
|
||
|
|
|
||
|
|
for _, tc := range []struct {
|
||
|
|
name string
|
||
|
|
op *types.TaskPendingOp
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "soft deleted",
|
||
|
|
op: makePendingOp(
|
||
|
|
types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb-deleted", "ingest", "deleted", nil,
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "missing",
|
||
|
|
op: makePendingOp(
|
||
|
|
types.TypeWikiIngest, types.TaskScopeKnowledgeBase, "kb-missing", "ingest", "missing", nil,
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{name: "tenant mismatch", op: &types.TaskPendingOp{
|
||
|
|
TenantID: 2, TaskType: types.TypeWikiIngest, Scope: types.TaskScopeKnowledgeBase,
|
||
|
|
ScopeID: "kb-active", Op: "ingest", DedupKey: "wrong-tenant",
|
||
|
|
}},
|
||
|
|
} {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
accepted, err := guard.EnqueueIfKnowledgeBaseActive(ctx, tc.op)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.False(t, accepted)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
var rows []*types.TaskPendingOp
|
||
|
|
require.NoError(t, db.Find(&rows).Error)
|
||
|
|
require.Len(t, rows, 1)
|
||
|
|
assert.Equal(t, "active", rows[0].DedupKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_IncrFailCount_ReturnsNewValueAndPersists exercises
|
||
|
|
// the UPDATE...RETURNING flow. Successive bumps should observe monotonic
|
||
|
|
// counts.
|
||
|
|
func TestTaskPendingOps_IncrFailCount_ReturnsNewValueAndPersists(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
op := makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k", nil)
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, op))
|
||
|
|
|
||
|
|
n, err := repo.IncrFailCount(ctx, op.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, 1, n)
|
||
|
|
|
||
|
|
n, err = repo.IncrFailCount(ctx, op.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, 2, n)
|
||
|
|
|
||
|
|
// Persisted value matches what was returned.
|
||
|
|
rows, err := repo.PeekBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, rows, 1)
|
||
|
|
assert.Equal(t, 2, rows[0].FailCount)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_PendingCount_ScopedTuple confirms the count covers
|
||
|
|
// only the (task_type, scope, scope_id) tuple.
|
||
|
|
func TestTaskPendingOps_PendingCount_ScopedTuple(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb-A", "ingest", "k1", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb-A", "ingest", "k2", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb-B", "ingest", "k3", nil)))
|
||
|
|
|
||
|
|
n, err := repo.PendingCount(ctx, "wiki:ingest", "knowledge_base", "kb-A")
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, int64(2), n)
|
||
|
|
|
||
|
|
n, err = repo.PendingCount(ctx, "wiki:ingest", "knowledge_base", "missing")
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, int64(0), n)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_DeleteByDedupKey_Filters tests the wiki delete-race
|
||
|
|
// helper: matching rows go away, others survive, optional op filter
|
||
|
|
// narrows the scope, and an empty dedup_key is rejected (so a buggy
|
||
|
|
// caller can't wipe the entire queue).
|
||
|
|
func TestTaskPendingOps_DeleteByDedupKey_Filters(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
// Two ingests + one retract, all keyed on knowledge "k1"; one ingest
|
||
|
|
// for unrelated "k2".
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k1", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k1", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "retract", "k1", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k2", nil)))
|
||
|
|
|
||
|
|
// Empty key is an error, queue unchanged.
|
||
|
|
err := repo.DeleteByDedupKey(ctx, "wiki:ingest", "knowledge_base", "kb", "", "")
|
||
|
|
assert.Error(t, err)
|
||
|
|
n, _ := repo.PendingCount(ctx, "wiki:ingest", "knowledge_base", "kb")
|
||
|
|
assert.Equal(t, int64(4), n)
|
||
|
|
|
||
|
|
// Drop only "ingest" rows for k1; retract survives.
|
||
|
|
require.NoError(t, repo.DeleteByDedupKey(ctx, "wiki:ingest", "knowledge_base", "kb", "k1", "ingest"))
|
||
|
|
rows, err := repo.PeekBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, rows, 2)
|
||
|
|
// The two survivors must be the retract for k1 and the ingest for k2.
|
||
|
|
keys := map[string]string{}
|
||
|
|
for _, r := range rows {
|
||
|
|
keys[r.Op] = r.DedupKey
|
||
|
|
}
|
||
|
|
assert.Equal(t, "k1", keys["retract"])
|
||
|
|
assert.Equal(t, "k2", keys["ingest"])
|
||
|
|
|
||
|
|
// Drop everything keyed on k1 regardless of op (empty op = wildcard).
|
||
|
|
require.NoError(t, repo.DeleteByDedupKey(ctx, "wiki:ingest", "knowledge_base", "kb", "k1", ""))
|
||
|
|
rows, err = repo.PeekBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, rows, 1)
|
||
|
|
assert.Equal(t, "k2", rows[0].DedupKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_ClaimBatch_MarksAndReturnsDisjoint verifies that
|
||
|
|
// ClaimBatch returns rows in FIFO order, stamps claimed_at, and that a
|
||
|
|
// second claim does NOT re-return the already-claimed rows (the disjoint
|
||
|
|
// property concurrent consumers rely on).
|
||
|
|
func TestTaskPendingOps_ClaimBatch_MarksAndReturnsDisjoint(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
for _, k := range []string{"k1", "k2", "k3"} {
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", k, nil)))
|
||
|
|
}
|
||
|
|
// Unrelated tuple must never be claimed.
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "other", "ingest", "z", nil)))
|
||
|
|
|
||
|
|
stale := time.Now().Add(-time.Hour)
|
||
|
|
|
||
|
|
first, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 2, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, first, 2)
|
||
|
|
assert.Equal(t, "k1", first[0].DedupKey)
|
||
|
|
assert.Equal(t, "k2", first[1].DedupKey)
|
||
|
|
assert.NotNil(t, first[0].ClaimedAt, "claimed_at should be stamped")
|
||
|
|
|
||
|
|
// Second claim skips the two already-claimed rows and returns the last.
|
||
|
|
second, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, second, 1)
|
||
|
|
assert.Equal(t, "k3", second[0].DedupKey)
|
||
|
|
|
||
|
|
// Nothing left to claim.
|
||
|
|
third, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Len(t, third, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_ClaimBatch_KeepsSameKeyTogether verifies the
|
||
|
|
// dedup_key affinity invariant: all rows sharing a knowledge_id are claimed
|
||
|
|
// in the SAME batch (never split), and `limit` counts distinct keys, not
|
||
|
|
// rows. This is what stops a concurrent batch from processing one op of a
|
||
|
|
// document while another batch processes a second op of the same document.
|
||
|
|
func TestTaskPendingOps_ClaimBatch_KeepsSameKeyTogether(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
// Document k1 has TWO queued ops (ingest then retract); k2 and k3 have
|
||
|
|
// one each. Enqueue order interleaves them so a naive row-ordered claim
|
||
|
|
// would split k1 across batches.
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k1", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k2", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "retract", "k1", nil)))
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k3", nil)))
|
||
|
|
|
||
|
|
stale := time.Now().Add(-time.Hour)
|
||
|
|
|
||
|
|
// limit=2 keys → k1 (both rows) + k2. k1 must NOT be split.
|
||
|
|
first, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 2, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, first, 3, "k1's two rows + k2's one row")
|
||
|
|
byKey := map[string]int{}
|
||
|
|
for _, r := range first {
|
||
|
|
byKey[r.DedupKey]++
|
||
|
|
}
|
||
|
|
assert.Equal(t, 2, byKey["k1"], "both k1 ops claimed together")
|
||
|
|
assert.Equal(t, 1, byKey["k2"])
|
||
|
|
assert.Zero(t, byKey["k3"], "k3 belongs to the next batch (limit was 2 keys)")
|
||
|
|
|
||
|
|
// A concurrent-style second claim gets the remaining key only — it can
|
||
|
|
// never see k1's rows again (disjoint).
|
||
|
|
second, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, second, 1)
|
||
|
|
assert.Equal(t, "k3", second[0].DedupKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_ClaimBatch_LateSiblingBlockedByFreshClaim verifies that a
|
||
|
|
// row enqueued for an already-claimed (in-flight) document is NOT claimed on
|
||
|
|
// its own: a dedup_key with any fresh claim is skipped WHOLESALE so the late
|
||
|
|
// sibling waits for the holder to finish (rows deleted → key freed) or for the
|
||
|
|
// claim to go stale, keeping same-document ops serialized across concurrent
|
||
|
|
// batches.
|
||
|
|
func TestTaskPendingOps_ClaimBatch_LateSiblingBlockedByFreshClaim(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k1", nil)))
|
||
|
|
stale := time.Now().Add(-time.Hour)
|
||
|
|
|
||
|
|
first, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 5, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, first, 1)
|
||
|
|
ingestID := first[0].ID
|
||
|
|
|
||
|
|
// A retract for the same, still-in-flight document arrives.
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "retract", "k1", nil)))
|
||
|
|
|
||
|
|
// The retract must NOT be claimed while the ingest claim is fresh —
|
||
|
|
// the whole k1 key is blocked.
|
||
|
|
second, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 5, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, second, 0, "late sibling blocked while holder's claim is fresh")
|
||
|
|
|
||
|
|
// Once the holder finishes (its claimed rows are deleted), the key is
|
||
|
|
// free and the retract becomes claimable.
|
||
|
|
require.NoError(t, repo.DeleteByIDs(ctx, []int64{ingestID}))
|
||
|
|
third, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 5, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, third, 1)
|
||
|
|
assert.Equal(t, "retract", third[0].Op)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_ClaimBatch_LateSiblingClaimableAfterStale verifies the
|
||
|
|
// other release path: if the holder CRASHES (claim never cleared), the whole
|
||
|
|
// key — original row + late sibling — becomes claimable together once the
|
||
|
|
// claim goes stale, so the pair is folded back into one batch.
|
||
|
|
func TestTaskPendingOps_ClaimBatch_LateSiblingClaimableAfterStale(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k1", nil)))
|
||
|
|
first, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 5, time.Now().Add(-time.Hour))
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, first, 1)
|
||
|
|
|
||
|
|
// Late retract arrives; holder then crashes (claim left stamped).
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "retract", "k1", nil)))
|
||
|
|
|
||
|
|
// A future stale threshold makes the crashed claim eligible again; both
|
||
|
|
// k1 rows are reclaimed together (never split).
|
||
|
|
got, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 5, time.Now().Add(time.Hour))
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, got, 2, "stale key reclaims both the ingest and the late retract together")
|
||
|
|
for _, r := range got {
|
||
|
|
assert.Equal(t, "k1", r.DedupKey)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_ClaimBatch_ReclaimsStale verifies a claim older than
|
||
|
|
// staleBefore is re-claimable (crash recovery), while a fresh claim is not.
|
||
|
|
func TestTaskPendingOps_ClaimBatch_ReclaimsStale(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k1", nil)))
|
||
|
|
|
||
|
|
// Claim it now.
|
||
|
|
got, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10, time.Now().Add(-time.Hour))
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, got, 1)
|
||
|
|
|
||
|
|
// A stale threshold in the past — the fresh claim is NOT stale yet.
|
||
|
|
got, err = repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10, time.Now().Add(-time.Hour))
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Len(t, got, 0, "fresh claim must not be re-claimed")
|
||
|
|
|
||
|
|
// A stale threshold in the future makes every existing claim stale.
|
||
|
|
got, err = repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10, time.Now().Add(time.Hour))
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, got, 1, "stale claim must be recoverable")
|
||
|
|
assert.Equal(t, "k1", got[0].DedupKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskPendingOps_ReleaseByIDs_ReturnsToPool verifies a released row
|
||
|
|
// becomes immediately claimable again without waiting for staleness.
|
||
|
|
func TestTaskPendingOps_ReleaseByIDs_ReturnsToPool(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskPendingOpsRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
op := makePendingOp("wiki:ingest", "knowledge_base", "kb", "ingest", "k1", nil)
|
||
|
|
require.NoError(t, repo.Enqueue(ctx, op))
|
||
|
|
|
||
|
|
stale := time.Now().Add(-time.Hour)
|
||
|
|
got, err := repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, got, 1)
|
||
|
|
|
||
|
|
// Claimed → not re-claimable.
|
||
|
|
got, err = repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, got, 0)
|
||
|
|
|
||
|
|
// Release → immediately claimable again.
|
||
|
|
require.NoError(t, repo.ReleaseByIDs(ctx, nil)) // no-op tolerated
|
||
|
|
require.NoError(t, repo.ReleaseByIDs(ctx, []int64{op.ID}))
|
||
|
|
got, err = repo.ClaimBatch(ctx, "wiki:ingest", "knowledge_base", "kb", 10, stale)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, got, 1)
|
||
|
|
assert.Equal(t, "k1", got[0].DedupKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------- TaskDeadLetterRepository ----------------
|
||
|
|
|
||
|
|
func makeDeadLetter(taskType, scope, scopeID, relatedID, lastErr string) *types.TaskDeadLetter {
|
||
|
|
return &types.TaskDeadLetter{
|
||
|
|
TenantID: 1,
|
||
|
|
TaskType: taskType,
|
||
|
|
Scope: scope,
|
||
|
|
ScopeID: scopeID,
|
||
|
|
RelatedID: relatedID,
|
||
|
|
Payload: json.RawMessage(`{"x":1}`),
|
||
|
|
LastError: lastErr,
|
||
|
|
FailCount: 5,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskDeadLetter_Insert_DefaultsAndAssignsID covers the empty-payload
|
||
|
|
// fallback and ID assignment.
|
||
|
|
func TestTaskDeadLetter_Insert_DefaultsAndAssignsID(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskDeadLetterRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
dl := &types.TaskDeadLetter{
|
||
|
|
TenantID: 1,
|
||
|
|
TaskType: "wiki:ingest",
|
||
|
|
ScopeID: "kb",
|
||
|
|
FailCount: 3,
|
||
|
|
// Scope intentionally empty — should default to "unknown".
|
||
|
|
// Payload intentionally nil — should default to "{}".
|
||
|
|
}
|
||
|
|
require.NoError(t, repo.Insert(ctx, dl))
|
||
|
|
assert.NotZero(t, dl.ID)
|
||
|
|
assert.Equal(t, types.TaskScopeUnknown, dl.Scope)
|
||
|
|
assert.Equal(t, json.RawMessage("{}"), dl.Payload)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskDeadLetter_Insert_RejectsMissingFields verifies the guard
|
||
|
|
// against rows that would leave the table without the columns ops queries
|
||
|
|
// rely on.
|
||
|
|
func TestTaskDeadLetter_Insert_RejectsMissingFields(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskDeadLetterRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
assert.Error(t, repo.Insert(ctx, nil))
|
||
|
|
assert.Error(t, repo.Insert(ctx, &types.TaskDeadLetter{ScopeID: "kb"}))
|
||
|
|
|
||
|
|
var n int64
|
||
|
|
db.Table("task_dead_letters").Count(&n)
|
||
|
|
assert.Equal(t, int64(0), n)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskDeadLetter_ListByScope_NewestFirstAndCursored exercises the
|
||
|
|
// cursor pagination path used by the ops console.
|
||
|
|
func TestTaskDeadLetter_ListByScope_NewestFirstAndCursored(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskDeadLetterRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
// Insert 5 rows for kb-A and 2 for kb-B.
|
||
|
|
for i := 0; i < 5; i++ {
|
||
|
|
require.NoError(t, repo.Insert(ctx, makeDeadLetter("wiki:ingest", "knowledge_base", "kb-A", "k", "boom")))
|
||
|
|
}
|
||
|
|
require.NoError(t, repo.Insert(ctx, makeDeadLetter("wiki:ingest", "knowledge_base", "kb-B", "k", "boom")))
|
||
|
|
require.NoError(t, repo.Insert(ctx, makeDeadLetter("wiki:ingest", "knowledge_base", "kb-B", "k", "boom")))
|
||
|
|
|
||
|
|
// First page of 2 from kb-A, newest first.
|
||
|
|
page1, cursor, err := repo.ListByScope(ctx, "knowledge_base", "kb-A", "", 2)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, page1, 2)
|
||
|
|
assert.True(t, page1[0].ID > page1[1].ID, "newest first")
|
||
|
|
require.NotEmpty(t, cursor)
|
||
|
|
|
||
|
|
// Second page of 2.
|
||
|
|
page2, cursor, err := repo.ListByScope(ctx, "knowledge_base", "kb-A", cursor, 2)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, page2, 2)
|
||
|
|
assert.True(t, page1[1].ID > page2[0].ID, "page2 should continue past page1")
|
||
|
|
require.NotEmpty(t, cursor)
|
||
|
|
|
||
|
|
// Last page — only 1 row left, cursor goes empty since len < limit.
|
||
|
|
page3, cursor, err := repo.ListByScope(ctx, "knowledge_base", "kb-A", cursor, 2)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, page3, 1)
|
||
|
|
assert.Empty(t, cursor)
|
||
|
|
|
||
|
|
// kb-B is isolated.
|
||
|
|
pageB, _, err := repo.ListByScope(ctx, "knowledge_base", "kb-B", "", 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, pageB, 2)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskDeadLetter_ListByScope_RejectsMissingScope guards the input
|
||
|
|
// validation in the public method.
|
||
|
|
func TestTaskDeadLetter_ListByScope_RejectsMissingScope(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskDeadLetterRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
_, _, err := repo.ListByScope(ctx, "", "kb", "", 10)
|
||
|
|
assert.Error(t, err)
|
||
|
|
_, _, err = repo.ListByScope(ctx, "knowledge_base", "", "", 10)
|
||
|
|
assert.Error(t, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskDeadLetter_ListByTaskType_FiltersAndPaginates is the cross-KB
|
||
|
|
// view: "all summary:generation failures" regardless of which KB they
|
||
|
|
// belong to.
|
||
|
|
func TestTaskDeadLetter_ListByTaskType_FiltersAndPaginates(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskDeadLetterRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
require.NoError(t, repo.Insert(ctx, makeDeadLetter("wiki:ingest", "knowledge_base", "kb-A", "k1", "")))
|
||
|
|
require.NoError(t, repo.Insert(ctx, makeDeadLetter("summary:gen", "knowledge_base", "kb-A", "k2", "")))
|
||
|
|
require.NoError(t, repo.Insert(ctx, makeDeadLetter("summary:gen", "knowledge_base", "kb-B", "k3", "")))
|
||
|
|
require.NoError(t, repo.Insert(ctx, makeDeadLetter("wiki:ingest", "knowledge_base", "kb-B", "k4", "")))
|
||
|
|
|
||
|
|
rows, _, err := repo.ListByTaskType(ctx, "summary:gen", "", 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, rows, 2)
|
||
|
|
for _, r := range rows {
|
||
|
|
assert.Equal(t, "summary:gen", r.TaskType)
|
||
|
|
}
|
||
|
|
|
||
|
|
_, _, err = repo.ListByTaskType(ctx, "", "", 10)
|
||
|
|
assert.Error(t, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestTaskDeadLetter_DeleteByID_IsIdempotent confirms a missing row does
|
||
|
|
// not produce an error — operators triggering concurrent deletes should
|
||
|
|
// see clean success.
|
||
|
|
func TestTaskDeadLetter_DeleteByID_IsIdempotent(t *testing.T) {
|
||
|
|
db := setupTaskQueueTestDB(t)
|
||
|
|
repo := NewTaskDeadLetterRepository(db)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
dl := makeDeadLetter("wiki:ingest", "knowledge_base", "kb", "k", "")
|
||
|
|
require.NoError(t, repo.Insert(ctx, dl))
|
||
|
|
|
||
|
|
require.NoError(t, repo.DeleteByID(ctx, dl.ID))
|
||
|
|
// Second delete on the same id should silently succeed.
|
||
|
|
require.NoError(t, repo.DeleteByID(ctx, dl.ID))
|
||
|
|
// Delete of unknown id should silently succeed.
|
||
|
|
require.NoError(t, repo.DeleteByID(ctx, 99999))
|
||
|
|
|
||
|
|
rows, _, err := repo.ListByScope(ctx, "knowledge_base", "kb", "", 10)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Len(t, rows, 0)
|
||
|
|
}
|