внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
+154
-129
@@ -174,186 +174,211 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и
|
||||
// компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major).
|
||||
// Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
|
||||
// нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения —
|
||||
// поле теряется только у задачи, попавшей к воркеру.
|
||||
// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`,
|
||||
// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре
|
||||
// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает
|
||||
// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения.
|
||||
//
|
||||
// Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса,
|
||||
// структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос
|
||||
// поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а
|
||||
// не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие
|
||||
// выполнялось бы само собой.
|
||||
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
|
||||
// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила
|
||||
// ниже держат оставшуюся пару плюс шаг схемы.
|
||||
const (
|
||||
repoPkg = "internal/adapter/repo/pocketbase"
|
||||
acquireFile = repoPkg + "/transcript_job_repo.go"
|
||||
mappingFile = repoPkg + "/job_mapping.go"
|
||||
mappingFile = repoPkg + "/record_mapping.go"
|
||||
migrationsPath = repoPkg + "/migrations"
|
||||
stageFile = "internal/entity/stage.go"
|
||||
stateFile = "internal/entity/audio_record.go"
|
||||
serviceFile = "internal/service/transcribe.go"
|
||||
)
|
||||
|
||||
// Колонки, которые заводит и заполняет само хранилище: перечня запроса они
|
||||
// касаются, а нашего кода — нет.
|
||||
// Колонки, которые заводит и заполняет само хранилище: нашего кода они не
|
||||
// касаются.
|
||||
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
|
||||
|
||||
func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) {
|
||||
query := acquireColumnNames(t)
|
||||
row := rowColumnNames(t)
|
||||
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
|
||||
written := writtenColumns(t)
|
||||
read := readColumns(t)
|
||||
|
||||
for _, col := range query {
|
||||
if !row[col] {
|
||||
for col := range written {
|
||||
if storageOwned[col] {
|
||||
continue
|
||||
}
|
||||
if !read[col] {
|
||||
t.Errorf(
|
||||
"колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+
|
||||
"она приедет нулевой, и первый Save затрёт сохранённое значение",
|
||||
"колонку %q пишет отображение записи, но recordToAudioRecord её не "+
|
||||
"читает: запись приедет из хранилища без этого поля",
|
||||
col,
|
||||
)
|
||||
}
|
||||
delete(row, col)
|
||||
}
|
||||
for col := range row {
|
||||
t.Errorf(
|
||||
"колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+
|
||||
"читает, и поле остаётся нулевым",
|
||||
col,
|
||||
)
|
||||
for col := range read {
|
||||
if storageOwned[col] {
|
||||
continue
|
||||
}
|
||||
if !written[col] {
|
||||
t.Errorf(
|
||||
"колонку %q читает recordToAudioRecord, но её не пишет ни "+
|
||||
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится",
|
||||
col,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) {
|
||||
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
|
||||
declared := schemaFieldNames(t)
|
||||
for _, col := range acquireColumnNames(t) {
|
||||
if col == "id" {
|
||||
continue // ключ заводит само хранилище, шаг схемы его не объявляет
|
||||
for col := range writtenColumns(t) {
|
||||
if storageOwned[col] {
|
||||
continue
|
||||
}
|
||||
if !declared[col] {
|
||||
t.Errorf(
|
||||
"колонка %q читается захватом, но ни один шаг схемы её не заводит: "+
|
||||
"запрос отвалится на живой базе",
|
||||
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
|
||||
"заводит: сохранение отвалится на живой базе",
|
||||
col,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Четвёртое место — путь через запись коллекции: `applyToRecord` пишет колонку,
|
||||
// `recordToJob` читает. Ищется литерал **в телах этих функций**, а не в файле:
|
||||
// в файле лежит и структура захвата со своими тегами `db:"…"`, и по ней условие
|
||||
// выполнялось бы само собой — правило было бы зелёным всегда.
|
||||
func TestКолонкиЗахватаЧитаютсяИЧерезЗапись(t *testing.T) {
|
||||
write := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
|
||||
// Рубеж объявлен одним дескриптором, но шаг под него пишется в другом месте, и
|
||||
// связь между ними компилятор не видит. Рубеж, оставшийся без шага, из работы не
|
||||
// выходит: воркер его захватит, шага не найдёт и остановит запись — а рубеж,
|
||||
// забытый в дескрипторе, не выдаётся захвату вовсе, и пустой прогон по
|
||||
// инварианту проекта не пишется в журнал и не считается в метрику.
|
||||
func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T) {
|
||||
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
|
||||
for _, stage := range workingStageIdents(t) {
|
||||
if !strings.Contains(body, "entity."+stage) {
|
||||
t.Errorf(
|
||||
"рубеж entity.%s объявлен рабочим в дескрипторе, но шага под него нет "+
|
||||
"в таблице stepFor: запись с этим рубежом остановится, не начав работы",
|
||||
stage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обратное направление того же правила: шаг, написанный под рубеж, которого в
|
||||
// дескрипторе нет, недостижим — захват такую запись не выдаст никогда.
|
||||
func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) {
|
||||
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
|
||||
declared := map[string]bool{}
|
||||
for _, stage := range stageIdents(t) {
|
||||
declared[stage] = true
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`case entity\.(\w+):`)
|
||||
for _, m := range re.FindAllStringSubmatch(body, -1) {
|
||||
if !declared[m[1]] {
|
||||
t.Errorf(
|
||||
"в таблице stepFor есть ветка для entity.%s, но такого рубежа нет в "+
|
||||
"дескрипторе: запись с этим рубежом захвату не выдаётся",
|
||||
m[1],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writtenColumns — колонки, которые пишет отображение записи в хранилище.
|
||||
func writtenColumns(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
|
||||
funcBody(t, mappingFile, "func applyToRecord(")
|
||||
read := funcBody(t, mappingFile, "func recordToJob(")
|
||||
|
||||
for _, col := range acquireColumnNames(t) {
|
||||
if storageOwned[col] {
|
||||
continue // эти колонки заводит и заполняет само хранилище
|
||||
}
|
||||
if !strings.Contains(write, `"`+col+`"`) {
|
||||
t.Errorf(
|
||||
"колонку %q читает захват, но её не пишет ни applyOwnedByPipeline, "+
|
||||
"ни applyToRecord: путь через запись коллекции её потеряет",
|
||||
col,
|
||||
)
|
||||
}
|
||||
if !strings.Contains(read, `"`+col+`"`) {
|
||||
t.Errorf(
|
||||
"колонку %q читает захват, но recordToJob её не читает: задача, "+
|
||||
"прочитанная не захватом, приедет без этого поля",
|
||||
col,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пятое условие того же инварианта: колонка, доехавшая до структуры захвата,
|
||||
// обязана попасть в задачу. `toJob` обращается к **полям**, а не к литералам,
|
||||
// поэтому сверяются имена полей, а не имена колонок: поле, забытое здесь,
|
||||
// приезжает из захвата прочитанным и теряется на последнем шаге.
|
||||
func TestПоляСтруктурыЗахватаДоезжаютДоЗадачи(t *testing.T) {
|
||||
body := funcBody(t, mappingFile, "func (r *acquiredRow) toJob()")
|
||||
for _, field := range rowFieldNames(t) {
|
||||
if !strings.Contains(body, "r."+field) {
|
||||
t.Errorf(
|
||||
"поле %s структуры захвата не читается в toJob: колонка приедет из "+
|
||||
"запроса, но в задачу не попадёт",
|
||||
field,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Чтение исходников ------------------------------------------------------
|
||||
|
||||
// acquireColumnNames достаёт имена колонок из константы `acquireColumns`. Она
|
||||
// склеена из строковых литералов, поэтому берётся текстом, а не разбором типов:
|
||||
// значение константы известно на месте.
|
||||
func acquireColumnNames(t *testing.T) []string {
|
||||
t.Helper()
|
||||
body := readFile(t, acquireFile)
|
||||
const marker = "const acquireColumns = "
|
||||
start := strings.Index(body, marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("в %s нет константы acquireColumns: правило потеряло предмет", acquireFile)
|
||||
}
|
||||
tail := body[start+len(marker):]
|
||||
end := strings.Index(tail, "`\n")
|
||||
if end < 0 {
|
||||
t.Fatalf("не нашёл конец константы acquireColumns в %s", acquireFile)
|
||||
}
|
||||
var cols []string
|
||||
for _, chunk := range strings.Split(strings.NewReplacer("`", "", "+", "", "\n", "", "\t", "").Replace(tail[:end]), ",") {
|
||||
if col := strings.TrimSpace(chunk); col != "" {
|
||||
cols = append(cols, col)
|
||||
}
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
t.Fatalf("перечень acquireColumns прочитан пустым: правило потеряло предмет")
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// rowColumnNames достаёт колонки из тегов `db:"…"` структуры `acquiredRow`.
|
||||
func rowColumnNames(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
out := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) {
|
||||
for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет")
|
||||
t.Fatalf("отображение записи не пишет ни одной колонки: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым
|
||||
// обращается `toJob`.
|
||||
func rowFieldNames(t *testing.T) []string {
|
||||
// readColumns — колонки, которые читает обратное отображение.
|
||||
func readColumns(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) {
|
||||
out = append(out, m[1])
|
||||
body := funcBody(t, mappingFile, "func recordToAudioRecord(")
|
||||
out := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет")
|
||||
t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rowStruct — текст объявления структуры `acquiredRow`.
|
||||
func rowStruct(t *testing.T) string {
|
||||
// stageIdents — имена констант рубежей, перечисленных дескриптором.
|
||||
func stageIdents(t *testing.T) []string {
|
||||
t.Helper()
|
||||
body := readFile(t, mappingFile)
|
||||
start := strings.Index(body, "type acquiredRow struct {")
|
||||
out, _ := stageDescriptor(t)
|
||||
return out
|
||||
}
|
||||
|
||||
// workingStageIdents — то же, но без конечного рубежа: из него запись в работу
|
||||
// не берут.
|
||||
func workingStageIdents(t *testing.T) []string {
|
||||
t.Helper()
|
||||
_, working := stageDescriptor(t)
|
||||
return working
|
||||
}
|
||||
|
||||
func stageDescriptor(t *testing.T) (all []string, working []string) {
|
||||
t.Helper()
|
||||
body := readFile(t, stageFile)
|
||||
const marker = "var stages = []Stage{"
|
||||
start := strings.Index(body, marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile)
|
||||
t.Fatalf("в %s нет дескриптора рубежей: правило потеряло предмет", stageFile)
|
||||
}
|
||||
end := strings.Index(body[start:], "\n}")
|
||||
if end < 0 {
|
||||
t.Fatalf("не нашёл конец структуры acquiredRow в %s", mappingFile)
|
||||
t.Fatalf("не нашёл конец дескриптора рубежей в %s", stageFile)
|
||||
}
|
||||
return body[start : start+end]
|
||||
|
||||
declared := declaredStates(t)
|
||||
re := regexp.MustCompile(`\{Name: (\w+)[^}]*\}`)
|
||||
for _, m := range re.FindAllStringSubmatch(body[start:start+end], -1) {
|
||||
if !declared[m[1]] {
|
||||
t.Errorf(
|
||||
"дескриптор называет рубеж %s, которого нет среди объявленных состояний "+
|
||||
"в %s: перечень схемы разошёлся бы с ним молча",
|
||||
m[1], stateFile,
|
||||
)
|
||||
continue
|
||||
}
|
||||
all = append(all, m[1])
|
||||
if !strings.Contains(m[0], "Terminal: true") {
|
||||
working = append(working, m[1])
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
t.Fatalf("дескриптор рубежей прочитан пустым: правило потеряло предмет")
|
||||
}
|
||||
if len(working) == 0 {
|
||||
t.Fatalf("в дескрипторе нет ни одного рабочего рубежа: правило потеряло предмет")
|
||||
}
|
||||
return all, working
|
||||
}
|
||||
|
||||
// declaredStates — константы рубежей, объявленные доменом.
|
||||
func declaredStates(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
out := map[string]bool{}
|
||||
re := regexp.MustCompile(`(?m)^\t(State\w+)\s*=\s*"`)
|
||||
for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Чтение исходников ------------------------------------------------------
|
||||
|
||||
// funcBody — текст тела функции от её заголовка до закрывающей скобки в первой
|
||||
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
|
||||
// потерявшее предмет, обязано краснеть, а не зеленеть.
|
||||
|
||||
Reference in New Issue
Block a user