Приём/UI: пачка фиксов границ и парсинга (F7–F10, N2)
Пять независимых bugfix'ов из ревью приёма (docs/backlog/review-f7-f10-ingest-ui-fixes.md): - F7: oversized .torrent через веб отдавал 500. Введён sentinel ingest.ErrTorrentTooLarge, classifyErr транслирует его в 400. - F8: гонка fast-path attach с cancel. Пред-рид FindReingestBlockingByInfohash больше не короткозамыкает активную запись — авторитетное дедуп-решение принимает CreateDownloadIfNoActive под BEGIN IMMEDIATE; короткозамыкание оставлено только для терминальных desync-записей (target_missing/orphaned). F6-апгрейд сохранён. - F9: magnet — регистронезависимый URN-префикс xt (RFC 2141); tgbot.ParseMessage срезает хвостовую пунктуацию, приклеенную жадным matchем. - F10: cap контекста до 16 KiB в ingest.Ingest (единственное место слияния — покрывает все транспорты), рунобезопасная обрезка + маркер. - N2: httpapi.shorten режет по рунам, не байтам — кириллица не рвётся в U+FFFD. Добавлены юнит-тесты на каждое исправленное поведение. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,11 @@ package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
||||
@@ -89,17 +91,21 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
|
||||
ctx = logctx.With(ctx, log)
|
||||
|
||||
// Быстрый дедуп-чек по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2):
|
||||
// Быстрый пред-рид по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2):
|
||||
// активная задача ЛИБО удерживающая источник desync-запись
|
||||
// (target_missing/orphaned) блокируют повторный приём. Для активной
|
||||
// авторитетная (атомарная) проверка — внутри CreateDownloadIfNoActive ниже;
|
||||
// desync-ветка сюда и завершается (в active-гард desync не заводим, чтобы не
|
||||
// размыть инвариант «≤1 активной»).
|
||||
// (target_missing/orphaned) блокируют повторный приём. Здесь короткозамыкаем
|
||||
// ТОЛЬКО desync-запись (терминальную): её CreateDownloadIfNoActive не увидит
|
||||
// (тот проверяет лишь активных), а состояния она не меняет. Активную же НЕ
|
||||
// короткозамыкаем — этот чек без транзакции, и в гонке с параллельным cancel
|
||||
// вернул бы stale «уже в работе» при пустом активном множестве. Авторитетное
|
||||
// дедуп-решение по активной примет CreateDownloadIfNoActive под BEGIN IMMEDIATE.
|
||||
if existing, err := s.store.FindReingestBlockingByInfohash(ctx, src.infohashes...); err != nil {
|
||||
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
|
||||
log.Error("ingest failed", "stage", "lookup-blocking", "error", err)
|
||||
return Result{}, fmt.Errorf("ingest: lookup blocking: %w", err)
|
||||
} else if existing != nil {
|
||||
} else if existing != nil && existing.State.IsTerminal() {
|
||||
// FindReingestBlockingByInfohash отдаёт терминальную запись только из
|
||||
// удерживающих desync-состояний (target_missing/orphaned) — присоединяемся.
|
||||
log.Info("download attached", "download_id", existing.ID, "state", existing.State)
|
||||
return s.attached(ctx, src, existing), nil
|
||||
}
|
||||
@@ -111,7 +117,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
d := &store.Download{
|
||||
SourceType: src.sourceType,
|
||||
SourceRef: src.sourceRef,
|
||||
Context: mergeContext(req.Context, src.synthContext),
|
||||
Context: capContext(mergeContext(req.Context, src.synthContext)),
|
||||
State: store.StateCatched,
|
||||
}
|
||||
// Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине.
|
||||
@@ -144,6 +150,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
// файлов → много piece-хешей) отсекаются здесь.
|
||||
const MaxTorrentSize = 8 << 20 // 8 MiB
|
||||
|
||||
// ErrTorrentTooLarge — принятый `.torrent` превышает MaxTorrentSize. Это промах
|
||||
// ввода пользователя (норма, не сбой сервера), поэтому транспорт транслирует его
|
||||
// в 400, а не 500 (веб MaxBytesReader пропускает файлы чуть больше лимита —
|
||||
// отсекает уже приём). Проверяется через errors.Is.
|
||||
var ErrTorrentTooLarge = errors.New("torrent too large")
|
||||
|
||||
// parsedSource — нормализованный источник приёма (magnet или .torrent).
|
||||
type parsedSource struct {
|
||||
sourceType store.SourceType
|
||||
@@ -159,7 +171,7 @@ type parsedSource struct {
|
||||
func (s *Service) parse(req Request) (parsedSource, error) {
|
||||
if len(req.TorrentData) > 0 {
|
||||
if len(req.TorrentData) > MaxTorrentSize {
|
||||
return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes", len(req.TorrentData), MaxTorrentSize)
|
||||
return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes: %w", len(req.TorrentData), MaxTorrentSize, ErrTorrentTooLarge)
|
||||
}
|
||||
info, err := torrent.Parse(req.TorrentData)
|
||||
if err != nil {
|
||||
@@ -195,6 +207,38 @@ func (s *Service) parse(req Request) (parsedSource, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MaxContextSize — предел размера контекста распознавания (пользовательский текст
|
||||
// + синтез из полей источника). Кап здесь, на единственном месте слияния,
|
||||
// покрывает все транспорты: REST ограничен телом (64 KiB), Telegram — лимитом
|
||||
// подписи, но веб-форма (multipart-бюджет на всё тело) иначе пропустила бы
|
||||
// мегабайты в поле context → в БД, рендер карточки и LLM-промпты.
|
||||
const MaxContextSize = 16 << 10 // 16 KiB
|
||||
|
||||
// contextTruncMarker дописывается к усечённому контексту как явный маркер.
|
||||
const contextTruncMarker = "\n…[контекст усечён]"
|
||||
|
||||
// capContext ограничивает контекст MaxContextSize байтами, обрезая по границе
|
||||
// руны (кириллица — 2 байта/руна; обрезка посреди руны дала бы U+FFFD) и добавляя
|
||||
// маркер усечения. Пустой/короткий контекст возвращается как есть.
|
||||
func capContext(s string) string {
|
||||
if len(s) <= MaxContextSize {
|
||||
return s
|
||||
}
|
||||
return trimToRune(s[:MaxContextSize]) + contextTruncMarker
|
||||
}
|
||||
|
||||
// trimToRune отбрасывает незавершённую многобайтовую руну на конце строки
|
||||
// (результат обрезки по фиксированному числу байт), не трогая корректный хвост.
|
||||
func trimToRune(s string) string {
|
||||
for len(s) > 0 {
|
||||
if r, size := utf8.DecodeLastRuneInString(s); r != utf8.RuneError || size > 1 {
|
||||
break
|
||||
}
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mergeContext склеивает контекст от транспорта с синтезом из полей magnet:
|
||||
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
|
||||
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
|
||||
|
||||
Reference in New Issue
Block a user