httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
This commit is contained in:
+40
-16
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("пустая версия витрины подписана горизонтом — подписывать нечем")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user