httpapi: точки метрики за период отдаются одним запросом

- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
This commit is contained in:
av
2026-08-04 18:46:45 +03:00
parent b819b77f62
commit 29ca8d415c
36 changed files with 4721 additions and 58 deletions
+40 -16
View File
@@ -147,7 +147,12 @@ func closeEnough(a, b float64) bool {
return math.Abs(a-b)/math.Max(math.Abs(a), math.Abs(b)) <= tolerance
}
func clipMetric(metric string) string {
// ClipMetric обрезает имя метрики для записи лога.
//
// Экспортирована потому, что предел один на всех, кто пишет имя метрики в лог:
// имя приходит из тела дословно при пределе приёма в 64 МиБ, а запись
// повторяется на каждый запрос. Второй предел разошёлся бы с первым молча.
func ClipMetric(metric string) string {
if len(metric) <= maxMetricInLog {
return metric
}
@@ -252,12 +257,38 @@ func (s *Service) Version(ctx context.Context) (string, error) {
s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
return "", err
}
return stamp(version, store.Now().Add(horizonSlack)), nil
return Stamp(version, Horizon()), nil
}
// stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
// Horizon — верхняя граница окна измерения на текущий момент.
//
// Экспортирована потому, что горизонт нужен ВСЕМ, кто объявляет измеренный род:
// маршрут точек снимает род и метку с одного горизонта, иначе метка подтвердит
// неизменность ответа, в котором род уже перевернулся ходом часов.
func Horizon() time.Time { return store.Now().Add(horizonSlack) }
// MeasureWindow — окно измерения рода для заданного горизонта.
//
// Одно на всех потребителей измерения. Второй экземпляр параметров разошёлся бы
// с первым молча, а вердикт зависит от каждого из них: слои сверки, размер окна
// и оба структурных порога уходят в предварительный отбор хранилища.
func MeasureWindow(horizon time.Time) store.CatalogWindow {
return store.CatalogWindow{
Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour),
Hours: Window,
Horizon: horizon,
CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints,
}
}
// Stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
// подписывать нечем — значит нечем, и горизонт этого не меняет.
func stamp(version string, horizon time.Time) string {
//
// Экспортирована по той же причине, что и Horizon: правило «метка строится из
// всего, от чего зависит ответ» держится ровно до тех пор, пока склейка одна.
func Stamp(version string, horizon time.Time) string {
if version == "" {
return ""
}
@@ -287,19 +318,12 @@ func New(st *store.Store, log *slog.Logger) *Service {
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
horizon := store.Now().Add(horizonSlack)
horizon := Horizon()
var snap store.CatalogSnapshot
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
var err error
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{
Fine: string(hae.LayerMinute),
Coarse: string(hae.LayerHour),
Hours: Window,
Horizon: horizon,
CoarsePoints: coarsePoints,
MinFinePoints: minFinePoints,
})
snap, err = s.store.ReadCatalog(ctx, MeasureWindow(horizon))
return err
})
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
@@ -331,7 +355,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
if basis.Conflicting > 0 {
s.log.WarnContext(ctx, "aggregation style conflict",
"capability", "query",
"metric", clipMetric(group.metric),
"metric", ClipMetric(group.metric),
"hours", basis.Hours,
"compared", basis.Compared,
"agreeing", basis.Agreeing,
@@ -344,7 +368,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
if to := group.latest(); to.After(horizon) {
s.log.WarnContext(ctx, "future data",
"capability", "query",
"metric", clipMetric(group.metric),
"metric", ClipMetric(group.metric),
"last_ts", store.FormatTime(to),
"horizon", store.FormatTime(horizon))
}
@@ -362,7 +386,7 @@ func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
// механизм не окупается вовсе.
s.log.DebugContext(ctx, "catalog unsigned", "capability", "query")
}
return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil
return Snapshot{Version: Stamp(version, horizon), Metrics: out}, nil
}
type metricGroup struct {
+26
View File
@@ -520,3 +520,29 @@ func TestКаталогОтдаётсяСВерсиейВитрины(t *testing
t.Error("каталог собран на стоящей витрине и остался без версии")
}
}
// Версия ответа каталога — это версия витрины ПЛЮС горизонт измерения.
//
// Утверждение прямое, потому что склейка теперь общая: её же зовёт маршрут
// точек. Сломай её — и оба маршрута начнут подтверждать неизменность ответа,
// чей род перевернулся ходом часов, а не коммитом.
func TestВерсияКаталогаНесётГоризонт(t *testing.T) {
st := openStore(t)
ctx := context.Background()
got, err := service(t, st).Version(ctx)
if err != nil {
t.Fatalf("Version: %v", err)
}
bare, err := st.StateVersion(ctx)
if err != nil {
t.Fatalf("StateVersion: %v", err)
}
if got == bare {
t.Error("версия ответа равна версии витрины — горизонт в неё не вошёл")
}
if want := catalog.Stamp(bare, catalog.Horizon()); got != want {
t.Errorf("версия ответа %q, ожидалась %q", got, want)
}
}
+3 -3
View File
@@ -15,16 +15,16 @@ func TestГоризонтВходитВВерсиюОтвета(t *testing.T) {
at := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
if stamp("v", at) == stamp("v", at.Add(2*time.Hour)) {
if Stamp("v", at) == Stamp("v", at.Add(2*time.Hour)) {
t.Error("версия не изменилась при сдвиге горизонта на два часа")
}
// Огрубление до часа точное, а не приблизительное: `hour_utc` объектов лежит
// ровно на часах, поэтому отбор меняется ровно при переходе через час.
// Внутри часа метка обязана стоять — иначе она дребезжала бы ежесекундно.
if stamp("v", at) != stamp("v", at.Add(20*time.Minute)) {
if Stamp("v", at) != Stamp("v", at.Add(20*time.Minute)) {
t.Error("версия сдвинулась внутри одного часа — метка дребезжит на месте")
}
if stamp("", at) != "" {
if Stamp("", at) != "" {
t.Error("пустая версия витрины подписана горизонтом — подписывать нечем")
}
}