удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
+45
-139
@@ -66,7 +66,6 @@ type TranscribeService struct {
|
||||
metaviewer contract.AudioMetaViewer
|
||||
converter contract.AudioFileConverter
|
||||
recognizer contract.AudioRecognizer
|
||||
tgSender contract.TelegramMessageSender
|
||||
limits entity.StuckLimits
|
||||
logger *slog.Logger
|
||||
}
|
||||
@@ -76,7 +75,6 @@ func NewTranscribeService(
|
||||
metaviewer contract.AudioMetaViewer,
|
||||
converter contract.AudioFileConverter,
|
||||
recognizer contract.AudioRecognizer,
|
||||
tgSender contract.TelegramMessageSender,
|
||||
limits entity.StuckLimits,
|
||||
logger *slog.Logger,
|
||||
) *TranscribeService {
|
||||
@@ -88,7 +86,6 @@ func NewTranscribeService(
|
||||
metaviewer: metaviewer,
|
||||
converter: converter,
|
||||
recognizer: recognizer,
|
||||
tgSender: tgSender,
|
||||
limits: limits,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -134,25 +131,14 @@ func (s *TranscribeService) stepFor(state string) (string, step, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.AudioRecord, error) {
|
||||
record := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
Source: entity.SourceTelegram,
|
||||
TgChatId: &chatId,
|
||||
TgReplyMessageId: &replyMsgId,
|
||||
}
|
||||
|
||||
return s.createRecord(ctx, record, file, fileName)
|
||||
}
|
||||
|
||||
// CreateJobFromApi заводит запись от имени вошедшего. Владелец обязателен:
|
||||
// пустой отвергается здесь, потому что колонка владельца допускает пустое
|
||||
// значение ради записей бота, и приём по HTTP — то место, где обязательность
|
||||
// держится.
|
||||
// CreateJobFromApi заводит запись от имени вошедшего. Владелец обязателен, и
|
||||
// обязательность эту держит схема хранилища: колонка владельца пустого значения
|
||||
// не принимает. Отказ стоит и здесь, раньше схемы, потому что отвечает
|
||||
// отправителю понятной ошибкой до того, как запись ляжет в хранилище: схема
|
||||
// отказала бы уже после укладки файла, а уборки файлов сервис не умеет.
|
||||
//
|
||||
// Отказ этот — последний рубеж, а не первый: предъявителя без учётной записи
|
||||
// пользователя транспорт отвергает раньше, до чтения тела. Здесь он остаётся на
|
||||
// случай нового вызывающего, который такой проверки не поставит.
|
||||
// Первый рубеж при этом ещё раньше: предъявителя без учётной записи пользователя
|
||||
// транспорт отвергает до чтения тела.
|
||||
func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName, ownerID string) (*entity.AudioRecord, error) {
|
||||
if ownerID == "" {
|
||||
s.logger.Error("Refusing to create record without owner")
|
||||
@@ -162,7 +148,7 @@ func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader
|
||||
record := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: &ownerID,
|
||||
OwnerID: ownerID,
|
||||
}
|
||||
|
||||
return s.createRecord(ctx, record, file, fileName)
|
||||
@@ -212,7 +198,7 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
|
||||
DurationMs: int64(info.Seconds) * 1000,
|
||||
}
|
||||
|
||||
fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, ownerOf(r))
|
||||
fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, r.OwnerID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
|
||||
return nil, err
|
||||
@@ -253,8 +239,8 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
|
||||
//
|
||||
// Контекст доходит до шага, а через него — до внешнего собеседника: остановка
|
||||
// сервиса убивает `ffmpeg` и обрывает запрос к распознаванию. Прерванный шаг
|
||||
// приговора не выносит: запись остаётся пригодной к повтору, отказа не тратит и
|
||||
// отправителю о несуществующем сбое не сообщает.
|
||||
// приговора не выносит: запись остаётся пригодной к повтору и отказа не
|
||||
// тратит.
|
||||
func (s *TranscribeService) RunStep(ctx context.Context) error {
|
||||
// Нас уже остановили — запись не забираем: захват стоил бы ей отказа, а
|
||||
// работы всё равно не будет. Исход «шаг не сделал ничего» — это
|
||||
@@ -275,8 +261,7 @@ func (s *TranscribeService) RunStep(ctx context.Context) error {
|
||||
// таблицы. Молчать нельзя: запись выпала бы из работы без единого следа.
|
||||
s.logger.Error("No step declared for state", "record_id", record.Id, "state", record.State)
|
||||
s.halt(record, holder, record.State, entity.HaltReasonStepFailed,
|
||||
fmt.Sprintf("no step for state %s", record.State),
|
||||
"сервис не знает, что делать с этой записью")
|
||||
fmt.Sprintf("no step for state %s", record.State))
|
||||
return &contract.NoopJobError{State: record.State}
|
||||
}
|
||||
|
||||
@@ -347,8 +332,7 @@ func (s *TranscribeService) acquire() (*entity.AudioRecord, string, error) {
|
||||
s.logger.Error("Record exhausted its attempts",
|
||||
"record_id", record.Id, "state", record.State, "attempts", record.Attempts)
|
||||
s.halt(record, acquired.Holder, record.State, entity.HaltReasonAttempts,
|
||||
fmt.Sprintf("attempts exhausted: %d", record.Attempts),
|
||||
"попытки исчерпаны")
|
||||
fmt.Sprintf("attempts exhausted: %d", record.Attempts))
|
||||
return nil, "", &contract.NoopJobError{State: record.State}
|
||||
}
|
||||
|
||||
@@ -360,8 +344,7 @@ func (s *TranscribeService) acquire() (*entity.AudioRecord, string, error) {
|
||||
"record_id", record.Id, "state", record.State,
|
||||
"state_entered_at", record.StateEnteredAt)
|
||||
s.halt(record, acquired.Holder, record.State, entity.HaltReasonStuck,
|
||||
fmt.Sprintf("stuck in %s", record.State),
|
||||
"обработка застряла")
|
||||
fmt.Sprintf("stuck in %s", record.State))
|
||||
return nil, "", &contract.NoopJobError{State: record.State}
|
||||
}
|
||||
|
||||
@@ -391,7 +374,7 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord
|
||||
|
||||
if r.OriginalFileID == nil {
|
||||
s.logger.Error("Record has no original file", "record_id", r.Id)
|
||||
return s.failStep(r, holder, stepNormalize, errors.New("record has no original file"), "у записи нет файла")
|
||||
return s.failStep(r, holder, stepNormalize, errors.New("record has no original file"))
|
||||
}
|
||||
|
||||
srcFile, err := s.repos.Files.GetByID(*r.OriginalFileID)
|
||||
@@ -441,7 +424,7 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord
|
||||
|
||||
s.logger.Error("File conversion failed",
|
||||
"error", err, "record_id", r.Id, "duration", conversionDuration)
|
||||
return s.failStep(r, holder, stepNormalize, err, "сбой конвертации файла")
|
||||
return s.failStep(r, holder, stepNormalize, err)
|
||||
}
|
||||
|
||||
destSize, err := dest.Size()
|
||||
@@ -457,7 +440,7 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord
|
||||
|
||||
destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
|
||||
destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs}
|
||||
destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, ownerOf(r))
|
||||
destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, r.OwnerID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id)
|
||||
return outcomeDone, err
|
||||
@@ -489,7 +472,7 @@ func (s *TranscribeService) submit(ctx context.Context, r *entity.AudioRecord, h
|
||||
|
||||
if r.NormalizedFileID == nil {
|
||||
s.logger.Error("Record has no normalized file", "record_id", r.Id)
|
||||
return s.failStep(r, holder, stepSubmit, errors.New("record has no normalized file"), "у записи нет приведённого файла")
|
||||
return s.failStep(r, holder, stepSubmit, errors.New("record has no normalized file"))
|
||||
}
|
||||
|
||||
fileRecord, err := s.repos.Files.GetByID(*r.NormalizedFileID)
|
||||
@@ -646,7 +629,7 @@ func (s *TranscribeService) uploadSource(
|
||||
func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) {
|
||||
if r.RecognitionID == nil {
|
||||
s.logger.Error("Record has no recognition attempt", "record_id", r.Id)
|
||||
return s.failStep(r, holder, stepPoll, errors.New("record has no recognition attempt"), "сведений о распознавании нет")
|
||||
return s.failStep(r, holder, stepPoll, errors.New("record has no recognition attempt"))
|
||||
}
|
||||
|
||||
attempt, err := s.repos.Recognitions.GetByID(*r.RecognitionID)
|
||||
@@ -656,7 +639,7 @@ func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, hol
|
||||
}
|
||||
if attempt.ExternalID == "" {
|
||||
s.logger.Error("Recognition attempt has no operation id", "record_id", r.Id)
|
||||
return s.failStep(r, holder, stepPoll, errors.New("recognition attempt has no operation id"), "сведений о распознавании нет")
|
||||
return s.failStep(r, holder, stepPoll, errors.New("recognition attempt has no operation id"))
|
||||
}
|
||||
|
||||
// Опрос идёт раз в несколько секунд всё время распознавания: часовая запись
|
||||
@@ -689,7 +672,7 @@ func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, hol
|
||||
errorText := result.GetError()
|
||||
s.logger.Error("Operation failed",
|
||||
"record_id", r.Id, "operation_id", attempt.ExternalID, "error_message", errorText)
|
||||
return s.failStep(r, holder, stepPoll, errors.New(errorText), "сбой при распознавании файла")
|
||||
return s.failStep(r, holder, stepPoll, errors.New(errorText))
|
||||
}
|
||||
|
||||
outcome, err := s.recognizer.Fetch(ctx, attempt.ExternalID)
|
||||
@@ -708,6 +691,17 @@ func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, hol
|
||||
"text_length", len(outcome.PlainText),
|
||||
"replicas", len(outcome.Replicas))
|
||||
|
||||
// Пустой результат — оплаченная наружу операция, из которой ничего не
|
||||
// вышло. Прежде этот случай был виден ответом отправителю («на записи нет
|
||||
// текста»), и вместе с доставкой видимость исчезла бы вовсе: запись дошла бы
|
||||
// до конца молча и от успешной не отличалась. Уровень — «может стать
|
||||
// проблемой»: конвенция журнала называет пустой текст распознавания
|
||||
// поимённым примером. Текста в записи нет по инварианту приватности, только
|
||||
// длина.
|
||||
if len(outcome.PlainText) == 0 && len(outcome.Replicas) == 0 {
|
||||
s.logger.Warn("Recognition returned empty text", "record_id", r.Id, "operation_id", attempt.ExternalID)
|
||||
}
|
||||
|
||||
// Сырой ответ сохраняется целиком: результат операции у провайдера не
|
||||
// переспрашивается, и когда мы научимся размечать говорящих, архив
|
||||
// пересчитается из сохранённого без единого рубля.
|
||||
@@ -749,55 +743,42 @@ func (s *TranscribeService) storeOutcome(r *entity.AudioRecord, outcome *entity.
|
||||
return nil
|
||||
}
|
||||
|
||||
// finish отвечает отправителю и доводит запись до конечного рубежа. Доставка —
|
||||
// хвост последнего шага, а не отдельный узел конвейера.
|
||||
// finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки
|
||||
// ответа отправителю у сервиса нет, и свой исход отправитель узнаёт опросом
|
||||
// готовности.
|
||||
func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) {
|
||||
text := "Ой, кажется, на аудиозаписи нет текста."
|
||||
if r.TranscriptTextID != nil {
|
||||
stored, err := s.repos.Texts.GetByID(*r.TranscriptTextID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to read transcript", "error", err, "record_id", r.Id)
|
||||
return outcomeDone, err
|
||||
}
|
||||
if stored.Contents != "" {
|
||||
text = stored.Contents
|
||||
}
|
||||
}
|
||||
|
||||
r.MoveToState(entity.StateDone)
|
||||
if err := s.repos.Records.Save(r, holder); err != nil {
|
||||
s.logger.Error("Failed to save record", "error", err, "record_id", r.Id)
|
||||
return outcomeDone, err
|
||||
}
|
||||
|
||||
return outcomeDone, s.send(r, text)
|
||||
return outcomeDone, nil
|
||||
}
|
||||
|
||||
// failStep останавливает запись приговором шага и сообщает отправителю.
|
||||
// failStep останавливает запись приговором шага.
|
||||
//
|
||||
// Исход возвращается **явно**: остановка отказом шага не является — шаг
|
||||
// рассудил об этой записи окончательно, — но и работой она не была, и
|
||||
// засчитывать её успехом нельзя.
|
||||
func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string, stepErr error, humanText string) (stepOutcome, error) {
|
||||
s.halt(r, holder, step, entity.HaltReasonStepFailed, stepErr.Error(),
|
||||
fmt.Sprintf("При обработке записи произошла ошибка: %s", humanText))
|
||||
func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string, stepErr error) (stepOutcome, error) {
|
||||
s.halt(r, holder, step, entity.HaltReasonStepFailed, stepErr.Error())
|
||||
return outcomeHalted, nil
|
||||
}
|
||||
|
||||
// halt ставит признак остановки, считает её отказом, пишет строку журнала
|
||||
// событий и сообщает отправителю.
|
||||
// halt ставит признак остановки, считает её отказом и пишет строку журнала
|
||||
// событий.
|
||||
//
|
||||
// Сообщение уходит при **любой** причине остановки: инвариант проекта «Принятая
|
||||
// запись не теряется молча» допускает два исхода — запись пригодна к повтору
|
||||
// либо об отказе сказано, — а остановленная запись захвату не выдаётся, значит
|
||||
// первый исход исключён.
|
||||
// Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не
|
||||
// теряется молча» держится теперь опросом готовности — остановка видна там
|
||||
// признаком — и журналом владельца, где у неё стоит причина.
|
||||
//
|
||||
// Счётчик растит **сама остановка**, а не воркер, и это не стилистика.
|
||||
// Остановка по сторожам наступает в захвате, до всякого шага, и воркер о ней
|
||||
// узнаёт признаком «работы нет» — а считать его в метрику запрещено инвариантом
|
||||
// «`NoopJobError` — не ошибка». Значит единственное место, где известны и факт
|
||||
// остановки, и рубеж, — здесь.
|
||||
func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, errText, humanText string) {
|
||||
func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, errText string) {
|
||||
// Рубеж читается до остановки: она его не двигает, но читать состояние после
|
||||
// мутации — привычка, из-за которой метка счётчика уже однажды разъехалась.
|
||||
stage := r.State
|
||||
@@ -823,8 +804,6 @@ func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, er
|
||||
outcome = entity.EventOutcomeFailed
|
||||
}
|
||||
s.appendEvent(r, entity.EventOriginPipeline, step, outcome, reason, 0)
|
||||
|
||||
s.notify(r, humanText+"\nПожалуйста, попробуйте еще раз.")
|
||||
}
|
||||
|
||||
// scheduleRetry снимает захват с отказавшей записи и ставит нарастающую паузу.
|
||||
@@ -885,69 +864,6 @@ func (s *TranscribeService) appendEvent(r *entity.AudioRecord, origin, step, out
|
||||
}
|
||||
}
|
||||
|
||||
// send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает
|
||||
// вверх: он принадлежит шагу.
|
||||
//
|
||||
// Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит
|
||||
// после того, как достигнутый рубеж сохранён: работа к этой минуте сделана, и
|
||||
// объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу записью
|
||||
// отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не появятся,
|
||||
// — поэтому причина недоставки живёт в журнале, а не в рубеже записи.
|
||||
func (s *TranscribeService) send(r *entity.AudioRecord, text string) error {
|
||||
if r.Source != entity.SourceTelegram {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Адресата у записи нет: отвечать некуда, и повторять нечего. Уровень здесь
|
||||
// выше, чем у неподнятого канала, и это не педантизм: пустой чат у записи
|
||||
// из Telegram — симптом порчи записи.
|
||||
if r.TgChatId == nil {
|
||||
s.undelivered(r, slog.LevelError, "chat is not specified")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.tgSender.Send(text, *r.TgChatId, r.TgReplyMessageId); err != nil {
|
||||
// Канал не поднят: сервис работает без этого входа, и это объявленный
|
||||
// режим, а не поломка.
|
||||
if errors.Is(err, contract.ErrDeliveryChannelDown) {
|
||||
s.undelivered(r, slog.LevelWarn, "delivery channel is down")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Error("Failed to sent message to client", "record_id", r.Id)
|
||||
return fmt.Errorf("failed to sent message to client, record id: %s, err: %w", r.Id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// undelivered записывает недоставленный ответ и считает его в метрику. Уровень
|
||||
// приходит от причины: объявленный режим — «может стать проблемой», порча
|
||||
// записи — событие для разбора.
|
||||
//
|
||||
// Идентификатор записи обязателен, иначе владелец видит, что ответ не ушёл, но
|
||||
// не может найти, чей; текста ответа в записи нет — он содержимое чужой записи.
|
||||
func (s *TranscribeService) undelivered(r *entity.AudioRecord, level slog.Level, reason string) {
|
||||
metrics.UndeliveredReplyCounter.WithLabelValues(reason).Inc()
|
||||
|
||||
switch level {
|
||||
case slog.LevelError:
|
||||
s.logger.Error(undeliveredMessage, "record_id", r.Id, "reason", reason)
|
||||
default:
|
||||
s.logger.Warn(undeliveredMessage, "record_id", r.Id, "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
const undeliveredMessage = "Reply was not delivered"
|
||||
|
||||
// notify отвечает отправителю там, где поднимать отказ некуда: запись уже
|
||||
// доведена до своего исхода, и отказ отправки остаётся записью в журнале.
|
||||
func (s *TranscribeService) notify(r *entity.AudioRecord, text string) {
|
||||
if err := s.send(r, text); err != nil {
|
||||
s.logger.Error("Failed to notify sender", "error", err, "record_id", r.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// closeWork убирает рабочую копию. Отказ уборки не роняет шаг, но и не
|
||||
// проглатывается: забытая копия это шестичасовая запись во временном каталоге.
|
||||
func (s *TranscribeService) closeWork(work contract.WorkFile) {
|
||||
@@ -956,16 +872,6 @@ func (s *TranscribeService) closeWork(work contract.WorkFile) {
|
||||
}
|
||||
}
|
||||
|
||||
// ownerOf — владелец записи строкой; пустая значит «владельца нет», и таковы
|
||||
// записи, принятые ботом. Файл наследует владельца своей записи: правило
|
||||
// просмотра коллекции файлов сужено этой колонкой.
|
||||
func ownerOf(r *entity.AudioRecord) string {
|
||||
if r.OwnerID == nil {
|
||||
return ""
|
||||
}
|
||||
return *r.OwnerID
|
||||
}
|
||||
|
||||
// formatOf приводит расширение к виду колонки формата: без точки, в нижнем
|
||||
// регистре. Наружу оно выходит только приведённым к перечню известных форматов —
|
||||
// это делает метка метрики.
|
||||
|
||||
Reference in New Issue
Block a user