Устойчивость раскладки и переходов linking (MAJOR-4, MINOR-7)
Закрывает две связанные дыры «claim-then-side-effect» в раскладке хардлинками. MINOR-7: transition глотал ошибку записи состояния — на путях Apply и авто-раскладки выполнение продолжалось к хардлинкам при незакоммиченном claim перехода в linking, а финальный linking→done отклонялся графом (задача застревала со stale-планом). Выделен transitionErr, возвращающий ошибку; Apply и finishRecognition прерываются ДО linkPlan при провале claim. Обёртка transition (void) сохранена для fire-and-forget переходов — соседние функции воркера не тронуты. MAJOR-4: (A) провал CreateFileLinks после создания хардлинков больше не оставляет задачу в linking голым return — уводим в review (код persist), повтор Apply идемпотентен. (B) новый шаг pollOnce sweepLinking возвращает осиротевшие после краха linking-задачи в review (код interrupted) на тике и старте; любая linking под w.mu устарела по построению. Восстановлен инвариант «у каждого нетерминального состояния есть владелец». Граф переходов не тронут (ребро linking→review уже объявлено). Тесты: провал claim не создаёт хардлинков; провал учёта уводит в review; sweep осиротевшего linking. OpenSpec-change linking-transition-robustness (дельты file-layout, state-reconciliation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -310,6 +310,10 @@ type memStore struct {
|
||||
links []store.FileLink
|
||||
candidates []store.MetadataCandidate
|
||||
torrents map[string][]byte
|
||||
|
||||
// Инъекция сбоев (для тестов устойчивости раскладки).
|
||||
failCreateLinks error // CreateFileLinks вернёт эту ошибку
|
||||
failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
@@ -436,6 +440,11 @@ func (m *memStore) GetDownload(_ context.Context, id string) (*store.Download, e
|
||||
}
|
||||
|
||||
func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
if m.failSetState != nil {
|
||||
if err := m.failSetState(st); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
d := m.downloads[id]
|
||||
d.State = st
|
||||
d.ErrorCode = store.NullString(code)
|
||||
@@ -523,6 +532,9 @@ func (m *memStore) ListOverrides(_ context.Context, id string) (map[string]strin
|
||||
}
|
||||
|
||||
func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) error {
|
||||
if m.failCreateLinks != nil {
|
||||
return m.failCreateLinks
|
||||
}
|
||||
m.links = append(m.links, links...)
|
||||
return nil
|
||||
}
|
||||
@@ -1547,3 +1559,103 @@ func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Устойчивость раскладки и переходов (MAJOR-4, MINOR-7) ---
|
||||
|
||||
// TestApply_ClaimFailureAbortsBeforeLinks (MINOR-7): если запись claim перехода
|
||||
// в linking падает, Apply ОБЯЗАН прерваться ДО создания хардлинков — иначе
|
||||
// ссылки лягут при задаче в review, а финальный linking→done граф отклонит.
|
||||
func TestApply_ClaimFailureAbortsBeforeLinks(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
f.st.failSetState = func(st store.State) error {
|
||||
if st == store.StateLinking {
|
||||
return errors.New("boom: claim persist failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := f.w.Apply(context.Background(), "1")
|
||||
if err == nil {
|
||||
t.Fatal("Apply must fail when linking claim persist fails")
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Errorf("state = %q, want review (claim not committed)", f.st.downloads["1"].State)
|
||||
}
|
||||
if len(f.st.links) != 0 {
|
||||
t.Errorf("file_links = %d, want 0 (no linking before committed claim)", len(f.st.links))
|
||||
}
|
||||
// Хардлинки на диск НЕ созданы — раскладка не запускалась.
|
||||
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
t.Errorf("hardlink %q created despite failed claim", dst)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApply_PersistFailureLeavesReview (MAJOR-4 A): хардлинки созданы, но
|
||||
// CreateFileLinks упал транзиентно → задача не должна застрять в linking; уходит
|
||||
// в review с причиной, повтор идемпотентен.
|
||||
func TestApply_PersistFailureLeavesReview(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
f.st.failCreateLinks = errors.New("boom: sqlite busy")
|
||||
|
||||
err := f.w.Apply(context.Background(), "1")
|
||||
if err == nil {
|
||||
t.Fatal("Apply must fail when persisting links fails")
|
||||
}
|
||||
if f.st.downloads["1"].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review (not stranded in linking)", f.st.downloads["1"].State)
|
||||
}
|
||||
if f.st.downloads["1"].ErrorCode.String != "persist" {
|
||||
t.Errorf("error_code = %q, want persist", f.st.downloads["1"].ErrorCode.String)
|
||||
}
|
||||
// Хардлинки уже на диске (учёт лишь не записан) — повторный Apply их допишет.
|
||||
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
|
||||
if _, statErr := os.Stat(dst); statErr != nil {
|
||||
t.Errorf("expected hardlink on disk despite persist failure: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepLinking_OrphanedToReview (MAJOR-4 B): задача, застрявшая в linking
|
||||
// после краха, на тике/старте возвращается в review с причиной.
|
||||
func TestSweepLinking_OrphanedToReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateLinking
|
||||
st.put(d)
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
w.sweepLinking(context.Background())
|
||||
|
||||
got := st.downloads["1"]
|
||||
if got.State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", got.State)
|
||||
}
|
||||
if got.ErrorCode.String != "interrupted" {
|
||||
t.Errorf("error_code = %q, want interrupted", got.ErrorCode.String)
|
||||
}
|
||||
if got.ErrorMsg.String == "" {
|
||||
t.Error("expected error_msg with reason")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepLinking_LeavesOtherStates: sweep трогает только linking, прочие
|
||||
// состояния (в т.ч. done) не задевает.
|
||||
func TestSweepLinking_LeavesOtherStates(t *testing.T) {
|
||||
st := newMemStore()
|
||||
done := completedDownload("1")
|
||||
done.State = store.StateDone
|
||||
st.put(done)
|
||||
review := completedDownload("2")
|
||||
review.State = store.StateReview
|
||||
st.put(review)
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
w.sweepLinking(context.Background())
|
||||
|
||||
if st.downloads["1"].State != store.StateDone {
|
||||
t.Errorf("done task moved to %q", st.downloads["1"].State)
|
||||
}
|
||||
if st.downloads["2"].State != store.StateReview {
|
||||
t.Errorf("review task moved to %q", st.downloads["2"].State)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user