Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
@@ -51,6 +51,20 @@ type Delivery struct {
|
||||
// имён. Ответ на вопрос «что останется потерянным, если тело удалить»:
|
||||
// ретеншен обязан спрашивать его прежде, чем срезать тело.
|
||||
UncoveredSections string
|
||||
// SkippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||
// Второй половина ответа на тот же вопрос: сущность, которую разбор не
|
||||
// понял, в витрину не попала, а список непокрытых секций про неё молчит.
|
||||
//
|
||||
// Отсутствие значения означает «не измерялось» и НЕ равно нулю: так
|
||||
// выглядят доставки, свёрнутые разбором, который пропусков не считал, и те,
|
||||
// чей разбор не досчитал. Читатель, принимающий по счётчику необратимое
|
||||
// решение, обязан трактовать отсутствие как «не удалять».
|
||||
//
|
||||
// Указателем, а не sql.NullInt64: поле уедет в JSON `/stats` и в MCP, а
|
||||
// NullInt64 сериализуется формой драйвера (`{"Int64":0,"Valid":false}`) —
|
||||
// первый, кто про это забудет, опубликует её наружу, и она станет
|
||||
// контрактом. Указатель даёт `null` бесплатно и означает ровно то же.
|
||||
SkippedEntities *int64
|
||||
}
|
||||
|
||||
// CreateDelivery записывает факт приёма пакета.
|
||||
@@ -92,21 +106,26 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
|
||||
const q = `
|
||||
SELECT id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, parse_status,
|
||||
points, headers, uncovered_sections
|
||||
points, headers, uncovered_sections, skipped_entities
|
||||
FROM delivery ORDER BY received_at DESC, id DESC LIMIT 1`
|
||||
|
||||
var d Delivery
|
||||
var receivedAt string
|
||||
// sql.NullInt64 живёт ровно на границе сканирования и наружу не выходит.
|
||||
var skipped sql.NullInt64
|
||||
err := s.db.QueryRowxContext(ctx, q).Scan(
|
||||
&d.ID, &receivedAt, &d.AutomationName, &d.AutomationID, &d.Aggregation,
|
||||
&d.Period, &d.SessionID, &d.Bytes, &d.SHA256, &d.RawPath,
|
||||
&d.ParseStatus, &d.Points, &d.Headers, &d.UncoveredSections)
|
||||
&d.ParseStatus, &d.Points, &d.Headers, &d.UncoveredSections, &skipped)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Delivery{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Delivery{}, fmt.Errorf("select last delivery: %w", err)
|
||||
}
|
||||
if skipped.Valid {
|
||||
d.SkippedEntities = &skipped.Int64
|
||||
}
|
||||
|
||||
d.ReceivedAt, err = ParseTime(receivedAt)
|
||||
if err != nil {
|
||||
@@ -120,11 +139,19 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
|
||||
//
|
||||
// Отдаются только **факты журнала**: то, что пришло вместе с доставкой.
|
||||
// Производные от разбора поля (`parse_status`, `points`, `derived_layer`,
|
||||
// `uncovered_sections`) сюда не попадают намеренно — перенос их в пересобранную
|
||||
// базу сделал бы витрину функцией предыдущего прогона. Особенно `derived_layer`:
|
||||
// доставка, чей повторный разбор отказал, отдала бы в наследование слой
|
||||
// прежнего разбора, и следующая доставка той же автоматизации унаследовала бы
|
||||
// его молча.
|
||||
// `uncovered_sections`, `skipped_entities`) сюда не попадают намеренно —
|
||||
// перенос их в пересобранную базу сделал бы витрину функцией предыдущего
|
||||
// прогона. Особенно `derived_layer`: доставка, чей повторный разбор отказал,
|
||||
// отдала бы в наследование слой прежнего разбора, и следующая доставка той же
|
||||
// автоматизации унаследовала бы его молча. У `skipped_entities` цена та же и
|
||||
// хуже: пустота у него значит «не измерялось», и перенесённое число выдавало бы
|
||||
// измерение прежнего разбора за измерение текущего — а по нему принимается
|
||||
// необратимое решение об удалении тела.
|
||||
//
|
||||
// Перечень пополняется ТЕМ ЖЕ изменением, которое заводит новое поле: он
|
||||
// единственное место, где сказано, чему нельзя пережить пересборку, и следующий
|
||||
// автор решает по нему. Поле, не внесённое сюда, однажды перенесут «для полноты
|
||||
// учёта».
|
||||
func (s *Store) ListDeliveries(ctx context.Context) ([]Delivery, error) {
|
||||
const q = `
|
||||
SELECT id, received_at, automation_name, automation_id, aggregation,
|
||||
@@ -255,6 +282,17 @@ type ParseOutcome struct {
|
||||
// у слоя пустота — отсутствие знания, у списка — знание об отсутствии.
|
||||
// Пересвёртка доставки, чья секция стала покрытой, обязана список очистить.
|
||||
Uncovered []string
|
||||
// SkippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||
// Пишется, когда разбор ДОСЧИТАЛ, включая ноль: доставка, пропуски которой
|
||||
// исчезли вместе с поумневшим разбором, не должна остаться помеченной
|
||||
// навсегда.
|
||||
//
|
||||
// nil означает «не измерялось» и колонку НЕ ТРОГАЕТ — та же идиома, что у
|
||||
// пустого Layer. Без неё отказ на чтении тела или паника разбора писали бы
|
||||
// ноль, то есть «проверено, терять нечего», в доставку, содержимое которой
|
||||
// никто не смотрел: ровно та подстановка, ради отказа от которой колонка
|
||||
// заведена без DEFAULT.
|
||||
SkippedEntities *int64
|
||||
}
|
||||
|
||||
func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) error {
|
||||
@@ -262,7 +300,8 @@ func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) er
|
||||
UPDATE delivery
|
||||
SET parse_status = ?, points = ?,
|
||||
derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END,
|
||||
uncovered_sections = ?
|
||||
uncovered_sections = ?,
|
||||
skipped_entities = CASE WHEN ? THEN ? ELSE skipped_entities END
|
||||
WHERE id = ?`
|
||||
|
||||
// Ровно одно представление пустоты — `[]`: nil-срез Go сериализуется как
|
||||
@@ -276,7 +315,17 @@ func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) er
|
||||
return fmt.Errorf("encode uncovered sections: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, out.Status, out.Points, out.Layer, out.Layer, string(encoded), id)
|
||||
// Отсутствие числа не пишется нулём: ноль означает «измерено, пропусков не
|
||||
// было», а нам нужно «не измерялось». Колонка остаётся какой была — та же
|
||||
// форма, что у слоя строкой выше.
|
||||
measured := out.SkippedEntities != nil
|
||||
var skipped int64
|
||||
if measured {
|
||||
skipped = *out.SkippedEntities
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, out.Status, out.Points, out.Layer, out.Layer,
|
||||
string(encoded), measured, skipped, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update parse status: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user