Files
xinmi/internal/application/service/memory/recovery_test.go
T
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

221 lines
9.5 KiB
Go

package memory
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func recoveryHarness(t *testing.T) (*Service, *gorm.DB, context.Context, *stubMessageRepo, *stubModelService, *stubEnqueuer) {
t.Helper()
svc, db, tenants := newMemoryHarness(t)
messages, models, queue := &stubMessageRepo{}, &stubModelService{response: `{"memories":[]}`}, &stubEnqueuer{}
svc.messageRepo, svc.modelService, svc.enqueuer = messages, models, queue
ctx := enabledCtx(t, tenants, 1, "alice")
messages.set("s", []*types.Message{userMessage("s", "recover this input", time.Now().Add(-time.Hour))})
return svc, db, ctx, messages, models, queue
}
func retryDue(t *testing.T, db *gorm.DB) {
t.Helper()
require.NoError(t, db.Model(&types.MemorySubject{}).Where("tenant_id = ?", 1).
Update("extract_next_at", time.Now().Add(-time.Second)).Error)
}
func TestRecoveryAfterRestartNeedsNoNewChatAndRejectsDuplicateTasks(t *testing.T) {
svc, db, ctx, _, models, queue := recoveryHarness(t)
ctx = context.WithValue(ctx, types.LanguageContextKey, "Chinese")
svc.ScheduleExtraction(ctx, "s", "m", "original-chat-model")
oldTask := queue.pop()
models.failNext = true
require.Error(t, svc.Handle(context.Background(), oldTask))
scope, err := ResolveScope(ctx)
require.NoError(t, err)
failed, err := svc.repo.GetSubject(ctx, scope)
require.NoError(t, err)
require.Equal(t, 1, failed.ExtractFailures)
require.True(t, failed.ExtractNextAt.After(time.Now()))
require.Nil(t, failed.ExtractCursor)
require.Equal(t, types.MemoryPendingSessions{"s"}, failed.PendingSessions)
// A replacement process has no in-memory retries or queued triggers.
restarted := &Service{repo: svc.repo, tenantRepo: svc.tenantRepo, messageRepo: svc.messageRepo, modelService: models, enqueuer: queue}
require.NoError(t, restarted.RecoverExtractions(context.Background()))
require.Nil(t, queue.pop(), "database deadline survives restart")
require.NoError(t, restarted.Handle(context.Background(), oldTask))
retryDue(t, db)
require.NoError(t, restarted.RecoverExtractions(context.Background()))
fresh := queue.pop()
require.NotNil(t, fresh)
var payload types.MemoryExtractPayload
require.NoError(t, json.Unmarshal(fresh.Payload(), &payload))
require.Equal(t, "original-chat-model", payload.ChatModelID)
require.Equal(t, "Chinese", payload.Language)
require.NoError(t, restarted.Handle(context.Background(), fresh))
calls := models.calls
require.NoError(t, restarted.Handle(context.Background(), fresh))
require.NoError(t, restarted.Handle(context.Background(), oldTask))
require.Equal(t, calls, models.calls, "duplicate/stale queue deliveries must not spend again")
completed, err := svc.repo.GetSubject(ctx, scope)
require.NoError(t, err)
require.Empty(t, completed.PendingSessions)
require.NotNil(t, completed.ExtractCursor)
require.Equal(t, 0, completed.ExtractFailures)
}
func TestNewChatDoesNotResetDurableBackoff(t *testing.T) {
svc, _, ctx, _, models, queue := recoveryHarness(t)
svc.ScheduleExtraction(ctx, "s", "m", "model")
models.failNext = true
require.Error(t, svc.Handle(context.Background(), queue.pop()))
scope, err := ResolveScope(ctx)
require.NoError(t, err)
before, err := svc.repo.GetSubject(ctx, scope)
require.NoError(t, err)
svc.ScheduleExtraction(ctx, "s", "new-message", "model")
after, err := svc.repo.GetSubject(ctx, scope)
require.NoError(t, err)
require.True(t, before.ExtractNextAt.Equal(*after.ExtractNextAt))
require.Equal(t, before.ExtractFailures, after.ExtractFailures)
require.Nil(t, after.ExtractScheduledAt)
require.Len(t, after.PendingSessions, 2)
require.Nil(t, queue.pop())
}
func TestRecoveryHonorsActiveLeaseAndFencesExpiredWorker(t *testing.T) {
svc, db, ctx, _, models, queue := recoveryHarness(t)
svc.ScheduleExtraction(ctx, "s", "m", "model")
oldTask := queue.pop()
var old types.MemoryExtractPayload
require.NoError(t, json.Unmarshal(oldTask.Payload(), &old))
scope, err := ResolveScope(ctx)
require.NoError(t, err)
started, err := svc.repo.StartExtraction(ctx, scope, old.AttemptID, extractInFlightGrace)
require.NoError(t, err)
require.True(t, started)
retryDue(t, db)
require.NoError(t, svc.RecoverExtractions(ctx))
require.Nil(t, queue.pop())
require.NoError(t, db.Model(&types.MemorySubject{}).Where("tenant_id = ?", 1).
Update("extract_lease_until", time.Now().Add(-time.Minute)).Error)
require.NoError(t, svc.RecoverExtractions(ctx))
require.Nil(t, queue.pop(), "crashed attempts receive durable backoff")
retryDue(t, db)
require.NoError(t, svc.RecoverExtractions(ctx))
fresh := queue.pop()
require.NotNil(t, fresh)
require.Error(t, svc.repo.FinishExtraction(types.WithMemoryExtractionAttempt(ctx, old.AttemptID), scope, types.MemorySessionCursors{"s": time.Now()}, "s"))
require.NoError(t, svc.repo.FailExtraction(ctx, scope, old.AttemptID))
require.NoError(t, svc.Handle(context.Background(), oldTask))
require.Zero(t, models.calls)
require.NoError(t, svc.Handle(context.Background(), fresh))
require.NotZero(t, models.calls)
}
func TestRecoveryRecordsQueueOutageAndRespectsWorkspaceAndSubjectSwitches(t *testing.T) {
svc, db, ctx, _, models, queue := recoveryHarness(t)
svc.enqueuer = nil
svc.ScheduleExtraction(ctx, "s", "m", "model")
scope, err := ResolveScope(ctx)
require.NoError(t, err)
subject, err := svc.repo.GetSubject(ctx, scope)
require.NoError(t, err)
require.Len(t, subject.PendingSessions, 1)
require.Equal(t, 1, subject.ExtractFailures)
svc.enqueuer = queue
tenant := svc.tenantRepo.(*stubTenantRepo)
tenant.set(1, &types.MemoryConfig{Enabled: false})
retryDue(t, db)
require.NoError(t, svc.RecoverExtractions(ctx))
require.Nil(t, queue.pop())
tenant.set(1, &types.MemoryConfig{Enabled: true, WriteMode: types.MemoryWriteAuto})
require.NoError(t, svc.repo.UpdateSubjectEnabled(ctx, scope, false))
retryDue(t, db)
require.NoError(t, svc.RecoverExtractions(ctx))
require.Nil(t, queue.pop())
require.NoError(t, svc.repo.UpdateSubjectEnabled(ctx, scope, true))
require.NoError(t, svc.RecoverExtractions(ctx))
fresh := queue.pop()
require.NotNil(t, fresh)
tenant.set(1, &types.MemoryConfig{Enabled: false})
require.NoError(t, svc.Handle(context.Background(), fresh))
require.Zero(t, models.calls, "a queued task rechecks current workspace configuration")
paused, err := svc.repo.GetSubject(ctx, scope)
require.NoError(t, err)
require.Equal(t, subject.ExtractFailures, paused.ExtractFailures, "ordinary disabled deferrals do not consume attempts")
}
func TestExhaustedRecoveryBudgetSurvivesRestartNewChatAndToggle(t *testing.T) {
svc, db, ctx, _, models, queue := recoveryHarness(t)
svc.ScheduleExtraction(ctx, "s", "m", "model")
for attempt := 1; attempt <= types.MaxMemoryExtractionFailures; attempt++ {
task := queue.pop()
require.NotNil(t, task)
models.failNext = true
require.Error(t, svc.Handle(context.Background(), task))
retryDue(t, db)
require.NoError(t, svc.RecoverExtractions(ctx))
}
require.Nil(t, queue.pop())
settings, err := svc.GetSettings(ctx)
require.NoError(t, err)
require.True(t, settings.ExtractionExhausted)
require.True(t, settings.ExtractionPending)
require.Equal(t, types.MaxMemoryExtractionFailures, settings.ExtractionFailures)
svc.ScheduleExtraction(ctx, "s", "new-turn", "model")
require.NoError(t, svc.SetEnabled(ctx, false))
require.NoError(t, svc.SetEnabled(ctx, true))
restarted := &Service{repo: svc.repo, tenantRepo: svc.tenantRepo, messageRepo: svc.messageRepo, modelService: models, enqueuer: queue}
require.NoError(t, restarted.RecoverExtractions(context.Background()))
require.Nil(t, queue.pop())
require.Equal(t, types.MaxMemoryExtractionFailures, models.callCount())
otherCtx := enabledCtx(t, svc.tenantRepo.(*stubTenantRepo), 1, "bob")
require.NoError(t, svc.RetryExtraction(otherCtx))
require.Nil(t, queue.pop(), "one subject cannot renew another subject's budget")
require.NoError(t, svc.RetryExtraction(ctx))
fresh := queue.pop()
require.NotNil(t, fresh)
require.NoError(t, svc.RetryExtraction(ctx))
require.Nil(t, queue.pop(), "manual retries cannot duplicate an active reservation")
require.NoError(t, svc.Handle(context.Background(), fresh))
settings, err = svc.GetSettings(ctx)
require.NoError(t, err)
require.False(t, settings.ExtractionExhausted)
require.False(t, settings.ExtractionPending)
require.Zero(t, settings.ExtractionFailures)
}
func TestRecoveryExcludesDisallowedAgentTurnsAndPriorContext(t *testing.T) {
svc, db, ctx, messages, models, queue := recoveryHarness(t)
base := time.Now().Add(-time.Hour)
secret := userMessage("s", "disabled Agent private preference", base)
visible := userMessage("s", "allowed Agent preference", base.Add(time.Second))
messages.set("s", []*types.Message{secret, visible})
messages.deniedMessages = map[string]bool{secret.ID: true}
svc.enqueuer = nil
svc.ScheduleExtraction(ctx, "s", "m", "model")
svc.enqueuer = queue
retryDue(t, db)
require.NoError(t, svc.RecoverExtractions(ctx))
require.NoError(t, svc.Handle(context.Background(), queue.pop()))
require.NotContains(t, models.lastPrompt, secret.Content)
require.Contains(t, models.lastPrompt, visible.Content)
}
func TestRecoveryDoesNotRecreateDeletedSubjectOrTrustLegacyPayload(t *testing.T) {
svc, db, ctx, _, models, queue := recoveryHarness(t)
svc.ScheduleExtraction(ctx, "s", "m", "model")
task := queue.pop()
require.NoError(t, db.Where("tenant_id = ?", 1).Delete(&types.MemorySubject{}).Error)
require.NoError(t, svc.Handle(context.Background(), task))
var count int64
require.NoError(t, db.Model(&types.MemorySubject{}).Count(&count).Error)
require.Zero(t, count)
require.Zero(t, models.calls)
}