ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add
- имя раздачи нормализуется на границе разбора: вырожденное `-` (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки вывода имени - контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и удерживается структурно; три транспорта перестали обещать идентификатор, которого нет, и коррелируют отказ по request_id - scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах воркера — записи об отказе qBittorrent и метабаз получили download_id и infohash; граница разбора bencode записана в docs/research
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user