ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add
- имя раздачи нормализуется на границе разбора: вырожденное `-` (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки вывода имени - контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и удерживается структурно; три транспорта перестали обещать идентификатор, которого нет, и коррелируют отказ по request_id - scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах воркера — записи об отказе qBittorrent и метабаз получили download_id и infohash; граница разбора bencode записана в docs/research
This commit is contained in:
@@ -432,7 +432,9 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res, err := s.deps.Ingestor.Ingest(r.Context(), req)
|
||||
if err != nil {
|
||||
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
||||
// Нулевой Result на любом пути ошибки — контракт ingest.Ingest;
|
||||
// корреляционный ключ веб-формы, как и REST, — request_id.
|
||||
redirectErr(w, r, userErr(r, err, ""))
|
||||
return
|
||||
}
|
||||
if res.Deduplicated {
|
||||
@@ -585,10 +587,10 @@ func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
|
||||
if err != nil {
|
||||
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit) —
|
||||
// тогда коррелируем по download_id, иначе (ранний разбор источника) по
|
||||
// request_id.
|
||||
s.apiErr(w, r, err, res.DownloadID)
|
||||
// Приём на любом пути ошибки возвращает нулевой Result (контракт
|
||||
// ingest.Ingest) — идентификатора загрузки тут нет и быть не может,
|
||||
// коррелируем по request_id.
|
||||
s.apiErr(w, r, err, "")
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
|
||||
@@ -2,6 +2,9 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -85,3 +88,50 @@ func TestUIAddUrlencoded(t *testing.T) {
|
||||
t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ приёма на HTTP-границе: идентификатора загрузки нет (контракт
|
||||
// ingest.Ingest — нулевой Result на любом пути ошибки), поэтому корреляционным
|
||||
// ключом остаётся request_id запроса. Проверяются оба HTTP-транспорта: REST
|
||||
// отдаёт ключ полем тела, веб-форма — текстом флеш-сообщения в редиректе.
|
||||
func TestIngestErrorCorrelatesByRequestID(t *testing.T) {
|
||||
t.Run("REST", func(t *testing.T) {
|
||||
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
|
||||
strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if s, _ := body["request_id"].(string); s == "" {
|
||||
t.Errorf("в теле отказа нет request_id: %v", body)
|
||||
}
|
||||
if _, ok := body["download_id"]; ok {
|
||||
t.Errorf("в теле отказа обещан download_id: %v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("веб-форма", func(t *testing.T) {
|
||||
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
|
||||
|
||||
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads",
|
||||
url.Values{"source": {"magnet:?xt=urn:btih:abc"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
loc := resp.Header.Get("Location")
|
||||
if !strings.Contains(loc, "request_id%3D") && !strings.Contains(loc, "request_id=") {
|
||||
t.Errorf("в сообщении отказа нет request_id: %q", loc)
|
||||
}
|
||||
if strings.Contains(loc, "download_id") {
|
||||
t.Errorf("в сообщении отказа обещан download_id: %q", loc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ type Request struct {
|
||||
Context string // подсказка для распознавания (опц.)
|
||||
}
|
||||
|
||||
// Result — итог приёма.
|
||||
// Result — итог приёма. При ненулевой ошибке Ingest возвращает НУЛЕВОЙ Result:
|
||||
// идентификатор загрузки, хеши, состояние и признак дедупликации не
|
||||
// публикуются (см. Ingest).
|
||||
type Result struct {
|
||||
DownloadID string
|
||||
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
|
||||
@@ -78,7 +80,24 @@ type Result struct {
|
||||
// полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в
|
||||
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
|
||||
// имени выполняет worker (см. download-tracking).
|
||||
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
//
|
||||
// Контракт: на ЛЮБОМ пути ошибки возвращается нулевой Result. Приём не создаёт
|
||||
// наблюдаемых последствий раньше, чем способен вернуть успех, а всё, что может
|
||||
// отказать после заведения загрузки, делает worker. Транспорты на это
|
||||
// опираются и не обещают идентификатора, которого нет: HTTP коррелирует отказ
|
||||
// по request_id, Telegram — ключа не даёт (см. ingest-спеку, требование
|
||||
// «Результат приёма при ошибке пуст»).
|
||||
//
|
||||
// Гарантия структурная — обнуление в одном defer, а не аккуратность каждой
|
||||
// ветки возврата: перечень веток растёт, и именно расхождение перечня с
|
||||
// комментариями транспортов породило исходный дефект.
|
||||
func (s *Service) Ingest(ctx context.Context, req Request) (res Result, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
res = Result{}
|
||||
}
|
||||
}()
|
||||
|
||||
src, err := s.parse(req)
|
||||
if err != nil {
|
||||
// Невалидный источник — норма (адресат не команда, а пользователь, и он
|
||||
@@ -179,10 +198,10 @@ func (s *Service) parse(req Request) (parsedSource, error) {
|
||||
}
|
||||
// SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес
|
||||
// добавления: torrent добавляется байтами (см. worker), не по SourceRef.
|
||||
// Фолбек на имя файла, если у раздачи нет содержательного имени
|
||||
// (пустое или NoName-сентинел "-").
|
||||
ref := strings.TrimSpace(info.DisplayName)
|
||||
if ref == "" || ref == "-" {
|
||||
// Фолбек на имя файла, если у раздачи нет содержательного имени:
|
||||
// вырожденное значение отбросил разборщик, здесь остаётся пустота.
|
||||
ref := info.DisplayName
|
||||
if ref == "" {
|
||||
ref = strings.TrimSpace(req.TorrentName)
|
||||
}
|
||||
return parsedSource{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -24,13 +25,22 @@ type fakeStore struct {
|
||||
upgradeID string // downloadID последнего вызова UpgradeCatchedMagnetToTorrent
|
||||
upgradeBlob []byte // байты, переданные в апгрейд
|
||||
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
|
||||
|
||||
lookupErr error // отказ хранилища на дедуп-чеке
|
||||
createErr error // отказ хранилища на заведении загрузки
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
||||
if f.lookupErr != nil {
|
||||
return nil, f.lookupErr
|
||||
}
|
||||
return f.active, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
|
||||
if f.createErr != nil {
|
||||
return nil, f.createErr
|
||||
}
|
||||
if f.active != nil {
|
||||
return f.active, nil
|
||||
}
|
||||
@@ -300,3 +310,31 @@ func TestIngestRejectsNonMagnet(t *testing.T) {
|
||||
t.Error("не должно быть записи задачи")
|
||||
}
|
||||
}
|
||||
|
||||
// Контракт приёма: на ЛЮБОМ пути ошибки транспорту возвращается НУЛЕВОЙ Result.
|
||||
// Транспорты на это опираются и не обещают идентификатора, которого нет
|
||||
// (см. ingest-спеку, «Результат приёма при ошибке пуст»). Сравниваем результат
|
||||
// с нулевым значением ЦЕЛИКОМ, а не по полю DownloadID: следующая ветвь отказа
|
||||
// может заполнить другое поле.
|
||||
func TestIngestReturnsZeroResultOnEveryErrorPath(t *testing.T) {
|
||||
boom := errors.New("boom")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fs *fakeStore
|
||||
req Request
|
||||
}{
|
||||
{"невалидный источник", &fakeStore{}, Request{Source: "не magnet и не torrent"}},
|
||||
{"сбой хранилища на дедуп-чеке", &fakeStore{lookupErr: boom}, Request{Source: sampleMagnet}},
|
||||
{"сбой хранилища на заведении", &fakeStore{createErr: boom}, Request{Source: sampleMagnet}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res, err := newService(tc.fs).Ingest(context.Background(), tc.req)
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
if !reflect.DeepEqual(res, Result{}) {
|
||||
t.Errorf("Result = %+v, want нулевой", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,28 +134,40 @@ func TestIngestTorrentTooLarge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// У раздачи без имени source_ref берётся из имени файла (фолбек).
|
||||
// У раздачи без содержательного имени source_ref берётся из имени файла.
|
||||
// Случая два, и они разные: раздача БЕЗ поля name (BestName() == "") и
|
||||
// раздача, объявившая вырожденное `-` (metainfo.NoName) — второй нормализует
|
||||
// разборщик, приём про него уже не знает.
|
||||
func TestIngestTorrentNameFallback(t *testing.T) {
|
||||
// Info без name → BestName() == "" → фолбек на TorrentName.
|
||||
info := metainfo.Info{Name: "", Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
infoName string
|
||||
}{
|
||||
{"без поля name", ""},
|
||||
{"вырожденное имя", metainfo.NoName},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
info := metainfo.Info{Name: tc.infoName, Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
|
||||
infoBytes, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
|
||||
var buf bytes.Buffer
|
||||
if err := mi.Write(&buf); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fs := &fakeStore{}
|
||||
_, err = newService(fs).Ingest(context.Background(),
|
||||
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
|
||||
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
|
||||
fs := &fakeStore{}
|
||||
_, err = newService(fs).Ingest(context.Background(),
|
||||
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
|
||||
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ package qbt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
)
|
||||
|
||||
// fakeQBittorrent — минимальный стенд WebUI API: требует cookie SID, выдаёт
|
||||
@@ -238,3 +241,102 @@ func TestLoginFailure(t *testing.T) {
|
||||
t.Error("ожидалась ошибка логина")
|
||||
}
|
||||
}
|
||||
|
||||
// captureHandler собирает записи журнала для проверки их полей. Свой WithAttrs
|
||||
// нужен обязательно: делегированный встроенному хендлеру вернул бы ЕГО, и
|
||||
// логгер, собранный через With (а scoped-логгер загрузки собирается именно
|
||||
// так), перестал бы захватываться.
|
||||
type captureHandler struct {
|
||||
attrs []slog.Attr
|
||||
records *[]slog.Record
|
||||
}
|
||||
|
||||
func (h *captureHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||
|
||||
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
rec := r.Clone()
|
||||
rec.AddAttrs(h.attrs...)
|
||||
*h.records = append(*h.records, rec)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *captureHandler) WithAttrs(as []slog.Attr) slog.Handler {
|
||||
return &captureHandler{attrs: append(append([]slog.Attr{}, h.attrs...), as...), records: h.records}
|
||||
}
|
||||
|
||||
func (h *captureHandler) WithGroup(string) slog.Handler { return h }
|
||||
|
||||
// recordFields сплющивает запись в map «ключ → значение как текст».
|
||||
func recordFields(r slog.Record) map[string]string {
|
||||
out := map[string]string{}
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
out[a.Key] = a.Value.String()
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Отказ qBittorrent на добавление («Fails.») причины не несёт — единственное,
|
||||
// что делает такую запись пригодной для разбора, это корреляция с загрузкой.
|
||||
// Инфохэша клиент не знает и знать не должен (разбор источника — единая точка
|
||||
// проекта): он берёт scoped-логгер из ctx. Негативная половина: magnet с
|
||||
// passkey приватного трекера уходит через это же поле формы, и в журнал он
|
||||
// попасть не должен (инвариант «секреты не попадают в логи»).
|
||||
func TestAddFailureRecordCarriesScopeAndNoSecret(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v2/auth/login" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "SID", Value: "token", Path: "/"})
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("Fails."))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Значение низкоэнтропийное намеренно: высокоэнтропийная фикстура «похожего
|
||||
// на секрет» вида краснит gitleaks в pre-commit. Различимости хватает —
|
||||
// тест ищет вхождение этой строки в полях записи.
|
||||
const passkey = "passkey-must-not-reach-the-journal"
|
||||
const magnetURL = "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6" +
|
||||
"&tr=http%3A%2F%2Ftracker.example%2Fann%3Fpasskey%3D" + passkey
|
||||
|
||||
var records []slog.Record
|
||||
log := slog.New(&captureHandler{records: &records}).
|
||||
With("capability", "review", "download_id", "01JB0000000000000000000000", "infohash",
|
||||
"541adcff3b6dd5dba7088ea83317d9d6fac331d6")
|
||||
ctx := logctx.With(context.Background(), log)
|
||||
|
||||
err := newClient(t, srv.URL).Add(ctx, AddRequest{URLs: []string{magnetURL}, Category: "jellybit"})
|
||||
if err == nil {
|
||||
t.Fatal("ожидался отказ добавления")
|
||||
}
|
||||
|
||||
var failure *slog.Record
|
||||
for i := range records {
|
||||
if records[i].Message == "external call failed" {
|
||||
failure = &records[i]
|
||||
}
|
||||
}
|
||||
if failure == nil {
|
||||
t.Fatalf("записи об отказе внешнего вызова нет, записей: %d", len(records))
|
||||
}
|
||||
f := recordFields(*failure)
|
||||
if f["download_id"] == "" {
|
||||
t.Errorf("в записи нет download_id из scoped-логгера: %v", f)
|
||||
}
|
||||
if f["infohash"] != "541adcff3b6dd5dba7088ea83317d9d6fac331d6" {
|
||||
t.Errorf("infohash = %q, want хеш из scoped-логгера", f["infohash"])
|
||||
}
|
||||
if f["ext.operation"] != "torrents/add" {
|
||||
t.Errorf("ext.operation = %q, want torrents/add", f["ext.operation"])
|
||||
}
|
||||
// Негативная половина: ни одно поле записи не несёт passkey.
|
||||
for k, v := range f {
|
||||
if strings.Contains(v, passkey) {
|
||||
t.Errorf("passkey утёк в поле %q: %q", k, v)
|
||||
}
|
||||
}
|
||||
if strings.Contains(failure.Message, passkey) {
|
||||
t.Errorf("passkey утёк в сообщение записи")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,10 +262,12 @@ func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
|
||||
func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Request) {
|
||||
res, err := b.ingestor.Ingest(ctx, req)
|
||||
if err != nil {
|
||||
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
|
||||
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
|
||||
// него (детали всё равно в логах на доменной границе).
|
||||
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
|
||||
// Приём на любом пути ошибки возвращает нулевой Result (контракт
|
||||
// ingest.Ingest) — идентификатора загрузки нет. Корреляционного ключа
|
||||
// у отказа приёма в Telegram нет вовсе (request_id — понятие
|
||||
// HTTP-границы): текст остаётся дружелюбным без ключа, детали — в
|
||||
// записи приёма на доменной границе (capability=ingest, infohash).
|
||||
b.send(chatID, opErr("Не удалось принять загрузку", ""), nil)
|
||||
return
|
||||
}
|
||||
if res.Deduplicated {
|
||||
|
||||
@@ -3,6 +3,7 @@ package tgbot
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -55,10 +56,15 @@ func (f *fakeAPI) GetFileDirectURL(string) (string, error)
|
||||
type fakeIngestor struct {
|
||||
lastReq ingest.Request
|
||||
res ingest.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
|
||||
f.lastReq = req
|
||||
if f.err != nil {
|
||||
// Контракт приёма: на ошибке результат нулевой (ingest.Ingest).
|
||||
return ingest.Result{}, f.err
|
||||
}
|
||||
return f.res, nil
|
||||
}
|
||||
|
||||
@@ -585,3 +591,26 @@ func TestBot_CallbackStaleButton(t *testing.T) {
|
||||
t.Errorf("answers = %v, want понятный ответ", api.answers)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ приёма в Telegram: идентификатора загрузки нет (контракт ingest.Ingest —
|
||||
// нулевой результат на любом пути ошибки), и обещать его пользователю нельзя.
|
||||
// Корреляционного ключа у этого транспорта тоже нет — записанный вопрос
|
||||
// изменения `ingest-nits`; тест фиксирует сегодняшнее состояние, чтобы
|
||||
// «download_id=» не вернулся в текст молча.
|
||||
func TestBot_IngestErrorPromisesNoDownloadID(t *testing.T) {
|
||||
b, api, ing, _ := newTestBot(t, []int64{7})
|
||||
ing.err = errors.New("boom")
|
||||
|
||||
b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))
|
||||
|
||||
if len(api.sent) != 1 {
|
||||
t.Fatalf("отправлено сообщений = %d, want 1", len(api.sent))
|
||||
}
|
||||
got := api.sent[0].text
|
||||
if !strings.Contains(got, "Не удалось принять загрузку") {
|
||||
t.Errorf("текст отказа = %q", got)
|
||||
}
|
||||
if strings.Contains(got, "download_id") || strings.Contains(got, idCode(tid)) {
|
||||
t.Errorf("в отказе обещан идентификатор загрузки: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ type File struct {
|
||||
type Info struct {
|
||||
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
|
||||
Infohashes []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2
|
||||
DisplayName string // имя раздачи (info.name)
|
||||
DisplayName string // имя раздачи (info.name), нормализованное displayName; пусто, если имени нет
|
||||
Files []File // файлы раздачи (для одиночного — один элемент)
|
||||
TotalLength int64 // суммарный размер
|
||||
Trackers []string // announce + announce-list (плоско, без дублей)
|
||||
@@ -69,7 +69,7 @@ func Parse(data []byte) (Info, error) {
|
||||
return Info{
|
||||
Infohash: hashes[0],
|
||||
Infohashes: hashes,
|
||||
DisplayName: meta.BestName(),
|
||||
DisplayName: displayName(meta.BestName()),
|
||||
Files: fs,
|
||||
TotalLength: total,
|
||||
Trackers: mi.UpvertedAnnounceList().DistinctValues(),
|
||||
@@ -77,6 +77,33 @@ func Parse(data []byte) (Info, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// displayName нормализует имя раздачи на границе разбора, чтобы ниже по потоку
|
||||
// (строка названия контекста, source_ref приёма, подсказка вывода отображаемого
|
||||
// имени) вырожденных значений не встречалось.
|
||||
//
|
||||
// Пробельное схлопываем тем же oneLine, что и комментарий: имя — недоверенный
|
||||
// вход, а контекст распознавания читается построчно, и имя с переводом строки
|
||||
// добавило бы в него строку, выглядящую как синтезированный нами факт. Защитой
|
||||
// от инъекции в промпт это НЕ является — пользовательский текст контекста
|
||||
// многострочен по замыслу; правило лишь снимает несогласованность между именем
|
||||
// и комментарием.
|
||||
//
|
||||
// Затем отбрасываем `metainfo.NoName` — раздача объявляет это значение сама,
|
||||
// полем name, по конвенции «имени нет» (библиотека экспортирует константу
|
||||
// именно затем, чтобы на неё ссылались). Раздача без поля name даёт пустое имя
|
||||
// и так.
|
||||
//
|
||||
// Порядок здесь существенный, и обратный — дефект: имя `" - "` сравнение с
|
||||
// сентинелом не проходит, а схлопывание превращает его ровно в сентинел, и тот
|
||||
// уезжает вниз по потоку всеми тремя путями.
|
||||
func displayName(name string) string {
|
||||
name = oneLine(name)
|
||||
if name == metainfo.NoName {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// files собирает файлы раздачи и суммарный размер из info (одиночный файл или
|
||||
// дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах
|
||||
// библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на
|
||||
@@ -111,8 +138,11 @@ var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.
|
||||
func (i Info) Context() string {
|
||||
var lines []string
|
||||
|
||||
if name := strings.TrimSpace(i.DisplayName); name != "" {
|
||||
lines = append(lines, name)
|
||||
// Имя уже нормализовано разборщиком (displayName): вырожденное значение и
|
||||
// разделители строк сюда не доходят. Проверка на пустоту остаётся — имени
|
||||
// у раздачи может не быть вовсе, и тогда строки названия просто нет.
|
||||
if i.DisplayName != "" {
|
||||
lines = append(lines, i.DisplayName)
|
||||
}
|
||||
if i.TotalLength > 0 {
|
||||
lines = append(lines, "Размер: "+humanSize(i.TotalLength))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -217,12 +218,48 @@ func TestParseInvalidBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextEmptyWhenNoFields(t *testing.T) {
|
||||
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов
|
||||
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая
|
||||
// функция — сетевых вызовов в коде нет по построению).
|
||||
// Раздача объявила вырожденное имя `-` (metainfo.NoName — конвенция «имени
|
||||
// нет», которую раздача выставляет САМА; библиотека его не синтезирует).
|
||||
// Разборщик нормализует его к пустой строке, поэтому строки названия в
|
||||
// контексте нет. Трекера и комментария тоже нет — строк-фактов минимум.
|
||||
func TestParseNoNameSentinelDropped(t *testing.T) {
|
||||
// Сентинел вокруг пробельного — тот же вырожденный вход: схлопывание
|
||||
// обязано идти ДО сравнения, иначе " - " превращается в "-" уже после
|
||||
// проверки и уезжает вниз по потоку (регрессия против master, где фолбек
|
||||
// source_ref на имя файла срабатывал).
|
||||
for _, name := range []string{
|
||||
metainfo.NoName,
|
||||
" - ",
|
||||
"-\n",
|
||||
"\t-",
|
||||
"\u00a0-",
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%q", name), func(t *testing.T) {
|
||||
data, _ := build(t, metainfo.Info{
|
||||
Name: name,
|
||||
Length: 0,
|
||||
PieceLength: 1024,
|
||||
Pieces: pieces(1),
|
||||
}, "", "")
|
||||
info, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if info.DisplayName != "" {
|
||||
t.Errorf("DisplayName = %q, want пусто (вырожденное имя)", info.DisplayName)
|
||||
}
|
||||
if got := info.Context(); got != "" {
|
||||
t.Errorf("Context() = %q, want пусто (строки названия быть не должно)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Раздача без поля name даёт пустое имя и без нормализации — случай отдельный
|
||||
// от вырожденного `-` и путать их нельзя (именно эта путаница в комментарии
|
||||
// теста и породила исходную нить ревью 2026-07-08).
|
||||
func TestParseMissingNameIsEmpty(t *testing.T) {
|
||||
data, _ := build(t, metainfo.Info{
|
||||
Name: "-", // metainfo.NoName
|
||||
Length: 0,
|
||||
PieceLength: 1024,
|
||||
Pieces: pieces(1),
|
||||
@@ -231,5 +268,50 @@ func TestContextEmptyWhenNoFields(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_ = info.Context() // не паникует; состав строк — по полям
|
||||
if info.DisplayName != "" {
|
||||
t.Errorf("DisplayName = %q, want пусто (поля name нет)", info.DisplayName)
|
||||
}
|
||||
if got := info.Context(); got != "" {
|
||||
t.Errorf("Context() = %q, want пусто", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя — недоверенный вход, а контекст читается построчно: имя с разделителем
|
||||
// строк не должно добавлять в контекст строку, выглядящую как синтезированный
|
||||
// нами факт. Число строк такое же, как у раздачи с обычным именем.
|
||||
func TestParseNameCollapsedToOneLine(t *testing.T) {
|
||||
// \n, U+2028 (LINE SEPARATOR) и U+2029 (PARAGRAPH SEPARATOR) — всё, чем можно
|
||||
// разорвать строку; краевые пробелы туда же.
|
||||
const dirty = " Dune.2024\nТрекер: evil.example\u2028Комментарий: подделка\u2029хвост "
|
||||
const want = "Dune.2024 Трекер: evil.example Комментарий: подделка хвост"
|
||||
data, _ := build(t, metainfo.Info{
|
||||
Name: dirty,
|
||||
Length: 2100,
|
||||
PieceLength: 1024,
|
||||
Pieces: pieces(3),
|
||||
}, "http://bt.rutracker.org/announce", "")
|
||||
info, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if info.DisplayName != want {
|
||||
t.Errorf("DisplayName = %q, want %q", info.DisplayName, want)
|
||||
}
|
||||
|
||||
clean, _ := build(t, metainfo.Info{
|
||||
Name: "Dune.2024",
|
||||
Length: 2100,
|
||||
PieceLength: 1024,
|
||||
Pieces: pieces(3),
|
||||
}, "http://bt.rutracker.org/announce", "")
|
||||
ref, err := Parse(clean)
|
||||
if err != nil {
|
||||
t.Fatalf("parse clean: %v", err)
|
||||
}
|
||||
gotLines := len(strings.Split(info.Context(), "\n"))
|
||||
wantLines := len(strings.Split(ref.Context(), "\n"))
|
||||
if gotLines != wantLines {
|
||||
t.Errorf("строк в контексте = %d, want %d (грязное имя добавило строк):\n%s",
|
||||
gotLines, wantLines, info.Context())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,15 @@ type fakeNamer struct {
|
||||
name string
|
||||
fields naming.Fields // извлечённая структура (ok=true, если Title непуст)
|
||||
gotContext string
|
||||
gotHint string // подсказка имени — третий потребитель torrent.Info.DisplayName
|
||||
calls int
|
||||
onCall func()
|
||||
}
|
||||
|
||||
func (f *fakeNamer) Derive(_ context.Context, contextText, _ string) (string, naming.Fields, bool) {
|
||||
func (f *fakeNamer) Derive(_ context.Context, contextText, hint string) (string, naming.Fields, bool) {
|
||||
f.calls++
|
||||
f.gotContext = contextText
|
||||
f.gotHint = hint
|
||||
if f.onCall != nil {
|
||||
f.onCall()
|
||||
}
|
||||
|
||||
@@ -404,6 +404,10 @@ func (w *Worker) Relink(ctx context.Context, id string) (err error) {
|
||||
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
|
||||
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity): иначе запись клиента об отказе уходит
|
||||
// без download_id/infohash.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
// Источник нужен для распознавания и должен быть докачан — проверяем
|
||||
// синхронно (без дебаунса); отсутствие приводит состояние к реальности
|
||||
// (orphaned/deleted), недокачанный — ErrNotReady.
|
||||
@@ -414,7 +418,6 @@ func (w *Worker) Relink(ctx context.Context, id string) (err error) {
|
||||
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
// Возврат в активную обработку — только через атомарный гард инварианта
|
||||
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
|
||||
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil {
|
||||
@@ -439,10 +442,13 @@ func (w *Worker) Rerecognize(ctx context.Context, id string) (err error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity): иначе запись клиента об отказе уходит
|
||||
// без download_id/infohash.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.ensureSourceReady(ctx, d, "rerecognize"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
logctx.From(ctx).Info("review re-recognizing without hint")
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
return nil
|
||||
@@ -462,10 +468,13 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity): иначе запись клиента об отказе уходит
|
||||
// без download_id/infohash.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.ensureSourceReady(ctx, d, "refine"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||
return fmt.Errorf("refine: %w", err)
|
||||
}
|
||||
@@ -688,6 +697,10 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) (e
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity): иначе запись клиента об отказе уходит
|
||||
// без download_id/infohash.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
@@ -724,6 +737,10 @@ func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID s
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity): иначе запись клиента об отказе уходит
|
||||
// без download_id/infohash.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add source: %w", err)
|
||||
@@ -809,7 +826,7 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
|
||||
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
|
||||
// выбор кандидата: логируем и продолжаем.
|
||||
if err := w.refreshDisplayNameLocked(ctx, id); err != nil {
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).
|
||||
logctx.From(ctx).
|
||||
Warn("display name refresh after candidate choice failed", "error", err)
|
||||
}
|
||||
return nil
|
||||
@@ -835,6 +852,10 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity): иначе запись клиента об отказе уходит
|
||||
// без download_id/infohash.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
// Режиссёр вручную заданного источника из метабазы (credits) — best-effort
|
||||
// косметика: сбой чтения рекогниции не валит смену источника (тип по умолчанию
|
||||
// movie, как трактует candMediaType(nil)).
|
||||
@@ -842,7 +863,7 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
|
||||
if w.recognizer != nil {
|
||||
rec, rerr := w.store.GetCurrentRecognition(ctx, id)
|
||||
if rerr != nil {
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).
|
||||
logctx.From(ctx).
|
||||
Warn("set provider: recognition lookup for director failed", "error", rerr)
|
||||
rec = nil
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
@@ -118,3 +121,162 @@ func TestRetryTorrentMissingBytesRollsBack(t *testing.T) {
|
||||
t.Errorf("state = %q, want failed (откат активации)", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// Третий потребитель torrent.Info.DisplayName — подсказка вывода отображаемого
|
||||
// имени, то есть вход LLM. Раздача, объявившая вырожденное имя `-`, не должна
|
||||
// отдавать его подсказкой: нормализация стоит на границе разбора, и здесь
|
||||
// проверяется, что она туда доехала (собственный оракул, а не транзитивный).
|
||||
func TestProcessCatchedNoNameGivesEmptyHint(t *testing.T) {
|
||||
data, ih := buildTorrent(t, metainfo.NoName)
|
||||
st := catchedTorrentStore("1", data, ih)
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
namer := &fakeNamer{name: "Дюна (2024)"}
|
||||
w.SetNamer(namer)
|
||||
|
||||
w.processCatched(context.Background())
|
||||
|
||||
if namer.calls == 0 {
|
||||
t.Fatal("namer не вызывался — тест ничего не проверил")
|
||||
}
|
||||
if namer.gotHint != "" {
|
||||
t.Errorf("подсказка имени = %q, want пусто (вырожденное имя раздачи)", namer.gotHint)
|
||||
}
|
||||
}
|
||||
|
||||
// probeHandler захватывает атрибуты, доклеенные логгером. Свой WithAttrs
|
||||
// обязателен: делегированный вернул бы чужой хендлер, и scoped-логгер (он
|
||||
// собирается через With) перестал бы захватываться.
|
||||
type probeHandler struct {
|
||||
attrs *[]slog.Attr
|
||||
}
|
||||
|
||||
func (h probeHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||
|
||||
func (h probeHandler) Handle(context.Context, slog.Record) error { return nil }
|
||||
|
||||
func (h probeHandler) WithAttrs(as []slog.Attr) slog.Handler {
|
||||
*h.attrs = append(*h.attrs, as...)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h probeHandler) WithGroup(string) slog.Handler { return h }
|
||||
|
||||
// Обязанность вызывающего (capability identity): команда, делающая вызов
|
||||
// внешнего сервиса в контексте загрузки, кладёт scoped-логгер в ctx ДО этого
|
||||
// вызова. Без этого запись клиента qBittorrent об отказе добавления уходит без
|
||||
// download_id/infohash, а причины отказа qBittorrent не сообщает («Fails.») —
|
||||
// корреляция там единственное, что делает запись пригодной для разбора.
|
||||
func TestRetryPassesScopedContextToAdd(t *testing.T) {
|
||||
data, ih := buildTorrent(t, "Fargo.mkv")
|
||||
st := catchedTorrentStore("1", data, ih)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
var attrs []slog.Attr
|
||||
w.log = slog.New(probeHandler{attrs: &attrs})
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if len(qb.addCtx) != 1 {
|
||||
t.Fatalf("вызовов Add = %d, want 1", len(qb.addCtx))
|
||||
}
|
||||
if logctx.FromOr(qb.addCtx[0], nil) == nil {
|
||||
t.Fatal("в ctx вызова Add нет scoped-логгера загрузки")
|
||||
}
|
||||
|
||||
got := map[string]string{}
|
||||
for _, a := range attrs {
|
||||
got[a.Key] = a.Value.String()
|
||||
}
|
||||
if got["download_id"] != "1" {
|
||||
t.Errorf("download_id = %q, want 1", got["download_id"])
|
||||
}
|
||||
if got["infohash"] != ih {
|
||||
t.Errorf("infohash = %q, want %q", got["infohash"], ih)
|
||||
}
|
||||
if got["capability"] == "" {
|
||||
t.Errorf("нет capability в scoped-логгере: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort ветки Retry: откат активации при провале Add и сбросы базиса
|
||||
// таймаутов/счётчика пропусков. Ветки переписаны этим изменением (ручные
|
||||
// w.log.… схлопнуты в logctx.From(ctx)), поэтому проверяем не только исход, но
|
||||
// и что аварийная запись несёт корреляцию из scoped-контекста — ради неё
|
||||
// scoped-логгер сюда и заводился.
|
||||
func TestRetryBestEffortBranchesCarryScope(t *testing.T) {
|
||||
t.Run("откат активации при провале Add", func(t *testing.T) {
|
||||
data, ih := buildTorrent(t, "Fargo.mkv")
|
||||
st := catchedTorrentStore("1", data, ih)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
qb := &fakeQbt{addErr: errors.New("qbit down")}
|
||||
w := newTestWorker(st, qb)
|
||||
st.setStateErr = errors.New("db down") // и откат тоже не проходит
|
||||
|
||||
var attrs []slog.Attr
|
||||
w.log = slog.New(probeHandler{attrs: &attrs})
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err == nil {
|
||||
t.Fatal("ожидалась ошибка добавления")
|
||||
}
|
||||
assertScopeAttrs(t, attrs, ih)
|
||||
})
|
||||
|
||||
t.Run("сброс базиса таймаутов не прошёл", func(t *testing.T) {
|
||||
data, ih := buildTorrent(t, "Fargo.mkv")
|
||||
st := catchedTorrentStore("1", data, ih)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
st.retriedErr = errors.New("db down")
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
var attrs []slog.Attr
|
||||
w.log = slog.New(probeHandler{attrs: &attrs})
|
||||
|
||||
// Best-effort: сам retry состоялся, несмотря на отказ сброса.
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||
}
|
||||
assertScopeAttrs(t, attrs, ih)
|
||||
})
|
||||
|
||||
t.Run("сброс счётчика пропусков не прошёл", func(t *testing.T) {
|
||||
data, ih := buildTorrent(t, "Fargo.mkv")
|
||||
st := catchedTorrentStore("1", data, ih)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
st.downloads["1"].SourceMissCount = 3
|
||||
st.missCntErr = errors.New("db down")
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
var attrs []slog.Attr
|
||||
w.log = slog.New(probeHandler{attrs: &attrs})
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
assertScopeAttrs(t, attrs, ih)
|
||||
})
|
||||
}
|
||||
|
||||
// assertScopeAttrs проверяет, что scoped-логгер загрузки собран и несёт
|
||||
// корреляционные поля.
|
||||
func assertScopeAttrs(t *testing.T, attrs []slog.Attr, infohash string) {
|
||||
t.Helper()
|
||||
got := map[string]string{}
|
||||
for _, a := range attrs {
|
||||
got[a.Key] = a.Value.String()
|
||||
}
|
||||
if got["download_id"] != "1" {
|
||||
t.Errorf("download_id = %q, want 1", got["download_id"])
|
||||
}
|
||||
if got["infohash"] != infohash {
|
||||
t.Errorf("infohash = %q, want %q", got["infohash"], infohash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,6 +1035,12 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
if d.State != store.StateFailed && d.State != store.StateStuck {
|
||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict)
|
||||
}
|
||||
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
|
||||
// вызывающего, capability identity). Иначе запись клиента qBittorrent об
|
||||
// отказе добавления уходит без download_id/infohash, а причины отказа
|
||||
// qBittorrent не сообщает («Fails.») — корреляция там единственное, что
|
||||
// делает запись пригодной для разбора. Форма та же, что у Delete.
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
|
||||
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — когда
|
||||
// источника в qBittorrent нет. Базис таймаута сбрасывается ниже через
|
||||
@@ -1076,8 +1082,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
// Байты torrent недоступны — откатываем активацию, задача не должна
|
||||
// «качаться» без раздачи в qBittorrent.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
w.log.Error("retry rollback failed",
|
||||
"capability", capReview, "download_id", id, "error", rbErr)
|
||||
logctx.From(ctx).Error("retry rollback failed", "error", rbErr)
|
||||
}
|
||||
return fmt.Errorf("retry: prepare add: %w", prepErr)
|
||||
}
|
||||
@@ -1085,8 +1090,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
// Активация уже прошла — откатываем задачу в прежнее состояние,
|
||||
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
w.log.Error("retry rollback failed",
|
||||
"capability", capReview, "download_id", id, "error", rbErr)
|
||||
logctx.From(ctx).Error("retry rollback failed", "error", rbErr)
|
||||
}
|
||||
return fmt.Errorf("retry: add to qbittorrent: %w", err)
|
||||
}
|
||||
@@ -1096,8 +1100,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
// stuck_after на ближайшем тике. Best-effort: сбой лишь лишает свежего окна
|
||||
// (WARN), сам retry уже состоялся.
|
||||
if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil {
|
||||
w.log.Warn("retry basis reset failed",
|
||||
"capability", capReview, "download_id", id, "error", err)
|
||||
logctx.From(ctx).Warn("retry basis reset failed", "error", err)
|
||||
}
|
||||
// Сброс счётчика пропусков источника: retried source_gone-задача иначе вошла бы
|
||||
// в downloading с source_miss_count == threshold и упала бы снова на ближайшем
|
||||
@@ -1105,11 +1108,10 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
// обещанного грейс-окна (MAJOR-3). Best-effort: сбой лишь лишает свежего окна.
|
||||
if d.SourceMissCount != 0 {
|
||||
if err := w.store.SetSourceMissCount(ctx, id, 0); err != nil {
|
||||
w.log.Warn("retry miss count reset failed",
|
||||
"capability", capReview, "download_id", id, "error", err)
|
||||
logctx.From(ctx).Warn("retry miss count reset failed", "error", err)
|
||||
}
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
|
||||
logctx.From(ctx).Info("state transition",
|
||||
"from", d.State, "to", store.StateDownloading)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ type fakeStore struct {
|
||||
transitions []transition
|
||||
torrents map[string][]byte // download_id → байты .torrent
|
||||
promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
|
||||
setStateErr error // если задан — SetDownloadState отказывает (ветка отката Retry)
|
||||
retriedErr error // если задан — SetRetriedAt отказывает (best-effort сброс базиса)
|
||||
missCntErr error // если задан — SetSourceMissCount отказывает (best-effort сброс счётчика)
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
@@ -166,6 +169,9 @@ func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string)
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
if f.setStateErr != nil {
|
||||
return f.setStateErr
|
||||
}
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
@@ -213,6 +219,9 @@ func (f *fakeStore) SetParsedContext(_ context.Context, id, jsonStr string) erro
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||
if f.missCntErr != nil {
|
||||
return f.missCntErr
|
||||
}
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
@@ -233,6 +242,9 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time)
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
|
||||
if f.retriedErr != nil {
|
||||
return f.retriedErr
|
||||
}
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
@@ -283,6 +295,7 @@ type fakeQbt struct {
|
||||
torrentsErr error
|
||||
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
|
||||
added []qbt.AddRequest
|
||||
addCtx []context.Context // ctx каждого вызова Add (проверка scoped-логгера)
|
||||
addErr error
|
||||
onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add)
|
||||
files []qbt.File
|
||||
@@ -321,7 +334,8 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
func (f *fakeQbt) Add(ctx context.Context, ar qbt.AddRequest) error {
|
||||
f.addCtx = append(f.addCtx, ctx)
|
||||
if f.onAdd != nil {
|
||||
f.onAdd()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user