ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add

- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
This commit is contained in:
av
2026-08-06 18:20:12 +03:00
parent 52615e4e49
commit d081ef1d30
30 changed files with 2190 additions and 63 deletions
+25 -6
View File
@@ -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{
+38
View File
@@ -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)
}
})
}
}
+32 -20
View File
@@ -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)
}
})
}
}