store: при равной полноте точек побеждает пришедшая доставка

- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
This commit is contained in:
av
2026-08-04 11:16:24 +03:00
parent ae607f1ceb
commit b278501a6e
44 changed files with 3780 additions and 172 deletions
+84 -2
View File
@@ -60,16 +60,28 @@ type Worker struct {
// ждала не воркера, а его появления, и сотня одинаковых WARN при первом же
// запуске обесценила бы уровень.
startupDone bool
// fold — свёртка одной доставки. Полем, а не прямым вызовом, ради ОДНОГО
// шва: исход `Deferred` наступает только от занятости базы, а удержать её
// по-настоящему стоит двадцать пять секунд (`busy_timeout` 5 с × 5 повторов
// транзакции) — столько же, сколько стоит `task verify:busy`, который в
// гейт намеренно не входит. Барьер журнального порядка при этом и есть
// механизм, на котором держится равенство «пересборка = приём», и оставить
// его без дешёвой проверки значило бы проверять его только тем прогоном,
// который никто не гоняет по расписанию.
fold func(ctx context.Context, deliveryID string) Outcome
}
// NewWorker собирает воркер над рабочей базой.
func NewWorker(st *store.Store, f *fold.Service, log *slog.Logger) *Worker {
return &Worker{
w := &Worker{
store: st,
player: Player{Fold: f},
log: log.With("capability", "fold-worker"),
wake: make(chan struct{}, 1),
}
w.fold = w.foldOne
return w
}
// Notify будит воркер. Вызывается приёмом после того, как доставка учтена.
@@ -169,7 +181,45 @@ func (w *Worker) Pass(ctx context.Context) (Outcome, error) {
return total, nil
}
lag.add(d)
total.Add(w.foldOne(ctx, d.ID))
// Порядок спрашивается ДО свёртки, а говорится ПОСЛЕ и только если
// свёртка состоялась: отложенная доставка витрину не трогала, и
// запись о свёртке вне порядка утверждала бы событие, которого не
// было, — да ещё повторялась бы каждым проходом, пока голова очереди
// занята. Спросить надо всё же до: после свёртки предикат уже видит
// саму эту доставку разобранной.
outOfOrder := w.outOfOrder(ctx, d)
out := w.fold(ctx, d.ID)
total.Add(out)
if outOfOrder && out.Deferred == 0 {
w.warnOutOfOrder(ctx, d)
}
if out.Deferred > 0 {
// Отложенная доставка ДЕРЖИТ очередь: перешагнув её, проход
// свернул бы её преемниц раньше неё, а тай-брейк равной полноты
// разрешается в пользу пришедшей доставки — то есть исход
// слияния стал бы функцией порядка свёртки, отличного от
// порядка журнала, и живая витрина разошлась бы с пересборкой
// молча, в значениях точек.
//
// Очередь от этого не встаёт: отложенным считается ТОЛЬКО
// занятость базы и отмена снаружи (store.Transient), а
// собственный дедлайн свёртки в него намеренно не входит —
// доставка, не уложившаяся в бюджет, получает `failed` и
// очередь освобождает. Занятость же блокирует запись всем
// одинаково: проход, перешагнувший занятую доставку, упёрся бы
// в ту же занятость на следующей.
//
// Метка отставания пишется ЗДЕСЬ, а не только на пустой
// выборке. Иначе барьер отменял бы её ровно в том состоянии,
// ради которого она заведена: занятая голова очереди не
// пропускает проход до пустой выборки никогда, и «очередь стоит
// два часа» стало бы неотличимо от «споткнулась один раз».
//
// Флаг первого прохода при этом НЕ взводится: проход до конца
// очереди не дошёл, и объявлять задолженность разобранной рано.
w.warnLag(ctx, lag)
return total, nil
}
cursor = d
}
}
@@ -216,6 +266,38 @@ func (w *Worker) foldOne(ctx context.Context, deliveryID string) Outcome {
return out
}
// warnOutOfOrder называет свёртку, идущую вне порядка журнала.
//
// Остаточное окно закрыть здесь нечем: метка `received_at` фиксируется при
// выпуске ULID, а строка учёта становится видимой только после записи тела
// (измерено 184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более
// ранней меткой появляется после того, как её преемница уже свёрнута. Барьер
// выше этого не ловит — отставшей доставки в момент прохода не существует.
//
// Молчать об этом нельзя: правило слияния точек стало функцией порядка свёртки,
// и перестановка оставляет в витрине версию не той доставки, что стоит в
// журнале последней. Лечится `healthlog reindex`; без этой строки узнать о
// необходимости неоткуда.
//
// Одна запись на доставку: событие редкое и адресное, шквала здесь не бывает.
// Отказ запроса свёртку НЕ прекращает — наблюдение не может стоить доставки.
func (w *Worker) outOfOrder(ctx context.Context, d store.PendingDelivery) bool {
later, err := w.store.ParsedAfter(ctx, d.ReceivedAt, d.ID)
if err != nil {
if ctx.Err() == nil {
w.log.ErrorContext(ctx, "journal order check failed", "error", err, "delivery_id", d.ID)
}
return false
}
return later
}
func (w *Worker) warnOutOfOrder(ctx context.Context, d store.PendingDelivery) {
w.log.WarnContext(ctx, "delivery folded out of journal order",
"delivery_id", d.ID,
"waited_sec", int64(store.Now().Sub(d.ReceivedAt).Seconds()))
}
// warnLag называет отставание одной строкой на проход.
//
// Одной, а не по строке на доставку: задолженность в сотню тел давала бы сотню