Files
qb/mqe/cumulative.go
2026-06-19 07:25:46 +03:00

586 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mqe
import (
"database/sql"
"fmt"
"time"
)
// func applyCorrections(corrections []model.F64Correction, measures []Measure) []Measure {
// for idx, measure := range measures {
// var isCorrected bool
// for _, correction := range corrections {
// if measure.Time >= correction.Time {
// measure.Value += correction.Value
// isCorrected = true
// }
// }
// if isCorrected {
// measures[idx] = measure
// }
// }
// return measures
// }
func applyCorrectionsToMeasure(corrections []_f64Correction, tm int64, value float64) float64 {
for _, correction := range corrections {
if tm >= correction.Time {
value += correction.Value
}
}
return value
}
func applyCorrectionsToMeasures(corrections []_f64Correction, measures []_measure) {
for idx, m := range measures {
for _, correction := range corrections {
if m.Time >= correction.Time {
m.Value += correction.Value
}
}
measures[idx] = m
}
}
func applyCorrectionsToRawMeasures(corrections []_f64Correction, measures []RawMeasure) {
for idx, m := range measures {
for _, correction := range corrections {
if m.Time >= correction.Time {
m.Value += correction.Value
}
}
measures[idx] = m
}
}
type _measure struct {
Time int64
Value float64
}
// ВАЖНО!
// ВО ВСЕХ ЗАПРОСАХ ОБЯЗАТЕЛЬНА КОРРЕКТНАЯ СОРТИРОВКА!
type _f64Correction struct {
Time int64
Value float64
}
func listF64CorrectionsTx(tx *sql.Tx, metricID int64) (list []_f64Correction, err error) {
rows, err := tx.Query(
"SELECT tm, value FROM metric_corrections WHERE metricID=? ORDER BY tm ASC",
metricID)
if err != nil {
if err == sql.ErrNoRows {
err = nil
}
return
}
defer rows.Close()
for rows.Next() {
var correction _f64Correction
err = rows.Scan(&correction.Time, &correction.Value)
if err != nil {
return
}
list = append(list, correction)
}
return
}
// result = append(result, Measure{
// Time: tm,
// Values: []float64{
// value,
// value - prevValue,
// },
// })
// listCumulativeMeasures - возвращает список CumulativeMeasure.
// Метод ничего не знает про FirstHourOfDay, но извне границы Since и Until могут быть
// скорректированы с учетом FirstHourOfDay. Это не поломает выборку.
func (s *MeasureQueryEngine) listCumulativeMeasures(req MetricMeasuresFilter) (_ []Measure, err error) {
tx, err := s.db.Driver().Begin()
if err != nil {
return
}
defer tx.Rollback()
// corrections, err := listF64CorrectionsTx(tx, req.MetricID)
// if err != nil {
// return
// }
// ВАЖНО!
// Оптимизация! Запрашиваем больше данных чем нужно, чтобы посчитать Total для
// самого старого измерения в result.
// Отнимаем от since еще 1 час (с запасом) чтобы зацепить extendedMeasure
extendedSince := req.Since - 3600
rows, err := tx.Query(`
SELECT tm, value
FROM f64
WHERE metricID=? AND tm BETWEEN ? AND ?
ORDER BY tm ASC`,
req.MetricID, extendedSince, req.Until)
if err != nil {
if err == sql.ErrNoRows {
err = nil
}
return
}
defer rows.Close()
var (
result []Measure
measures []_measure
prev *_measure
)
// Вычитываем все найденные показания
for rows.Next() {
var m _measure
err = rows.Scan(&m.Time, &m.Value)
if err != nil {
return
}
if m.Time < req.Since {
// пока не найдено первое показание внутри диапазона обновлем extendedValue
prev = &_measure{
Time: m.Time,
Value: m.Value,
}
} else {
measures = append(measures, m)
break
}
}
// Остальные показания вычитываем без проверок
for rows.Next() {
var m _measure
err = rows.Scan(&m.Time, &m.Value)
if err != nil {
return
}
measures = append(measures, m)
}
if err = rows.Err(); err != nil {
return
}
if len(measures) == 0 {
return
}
if prev == nil {
// extended not found
// extended показание не найдено, а значит для первого показания не сможем показать total.
// Нужен дополнительный запрос.
// Находим максимально свежее показание метрики до extendedSince, от которого и будем
// считать total.
var (
tm int64
value float64
)
err = tx.QueryRow(`
SELECT tm, value
FROM f64
WHERE metricID=? AND tm < ?
ORDER BY tm DESC
LIMIT 1`,
req.MetricID, extendedSince).Scan(&tm, &value)
if err != nil {
if err != sql.ErrNoRows {
return
}
err = nil
} else {
prev = &_measure{
Time: tm,
Value: value,
}
}
}
// ВАЖНО!
// Сперва применяем коррекции к показаниям, а затем рассчитываем сумму за период
// if len(corrections) > 0 {
// applyCorrectionsToMeasures(corrections, measures)
// if prev != nil {
// prev.Value = applyCorrectionsToMeasure(corrections, prev.Time, prev.Value)
// }
// }
var prevValue float64
if prev != nil {
prevValue = prev.Value
}
for _, m := range measures {
result = append(result, Measure{
Time: m.Time,
Values: []float64{
m.Value,
m.Value - prevValue,
},
})
prevValue = m.Value
}
// Корректируем самый первый тотал. Eсли prev показания не было -> total = 0
if prev == nil {
first := result[0]
first.Values[1] = 0
result[0] = first
}
return result, nil
}
type AggregatedMeasuresFilter struct {
MetricID int64 `json:"metricID"`
Since int64 `json:"since"`
Until int64 `json:"until"`
GroupBy GroupBy `json:"groupBy"`
LastDayOfMonth int `json:"lastDayOfMonth"` // например, конец месяца 25 число
FirstHourOfDay int `json:"firstHourOfDay"` // например день начинается в 6:00
}
// listAggregatedCumulativeMeasures - возвращает список AggregatedCumulativeMeasure,
// сгрупированный по какому-то периоду
func (s *MeasureQueryEngine) listAggregatedCumulativeMeasures(req AggregatedMeasuresFilter) (_ []AggregatedMeasure, err error) {
//pretty.PPrintln("listAggregatedCumulativeMeasures", req)
tx, err := s.db.Driver().Begin()
if err != nil {
return
}
defer tx.Rollback()
corrections, err := listF64CorrectionsTx(tx, req.MetricID)
if err != nil {
return
}
var (
// чтобы не экранировать символы в шаблоне строки
groupByFormat string
parsePeriodLayout string
)
switch req.GroupBy {
case ByHour:
groupByFormat = "%Y%m%d%H"
parsePeriodLayout = hourPeriodLayout
case ByDay:
groupByFormat = "%Y%m%d"
parsePeriodLayout = dayPeriodLayout
case ByMonth:
groupByFormat = "%Y%m"
parsePeriodLayout = monthPeriodLayout
default:
// Защита от дурака
err = fmt.Errorf("unknown groupBy: %s", req.GroupBy)
return
}
// ВАЖНО!
// Оптимизация! Запрашиваем больше данных чем нужно, чтобы посчитать начало
// первого и конец последнего периодов без дополнительных запросов.
//
// Отнимаем 3 минуты чтобы зацепить последнее показание предыдущего периода
extendedSince := req.Since - 3*60
// Добавляем 1 час, чтобы зацепить первое показание следующего периода
extendedUntil := req.Until + 60*60
//fmt.Printf("extendedSince: %s\n\n", extendedSince)
// Для каждого периода выбираем первое и последнее (по времени) измерение.
// Если делать выборку одним запросом без UNION, а именно сделать
// INNER JOIN ... ON f64.tm=x.maxtm OR f64.tm=x.mintm - все начинает тормозить.
// Два запроса работают достаточно быстро.
// ВАЖНО!
// Не забыть про сортировку tm DESC!
dt, err := getPeriodDateTime(req.GroupBy, req.LastDayOfMonth, req.FirstHourOfDay)
if err != nil {
return
}
//fmt.Printf("DT: %s\n\n", dt)
query := fmt.Sprintf(`
WITH
measures AS (SELECT * FROM f64 WHERE metricID=? AND tm BETWEEN ? AND ?),
aggregated AS (
SELECT MIN(tm) as minTm, MAX(tm) as maxTm, DATE_FORMAT(%s, '%s') as periodStr
FROM measures
GROUP BY periodStr
)
SELECT aggregated.periodStr, firstMeasures.tm, firstMeasures.value, lastMeasures.tm, lastMeasures.value
FROM aggregated
INNER JOIN measures firstMeasures ON firstMeasures.tm = aggregated.minTm
INNER JOIN measures lastMeasures ON lastMeasures.tm = aggregated.maxTm
ORDER BY aggregated.periodStr ASC`,
dt, groupByFormat)
rows, err := tx.Query(query, req.MetricID, extendedSince, extendedUntil)
if err != nil {
if err == sql.ErrNoRows {
err = nil
}
return
}
defer rows.Close()
var result []AggregatedMeasure
periodCalculator := NewPeriodCalculator(PeriodCalculatorOptions{
GroupBy: req.GroupBy,
LastDayOfMonth: req.LastDayOfMonth,
FirstHourOfDay: req.FirstHourOfDay,
Since: time.Unix(req.Since, 0),
Until: time.Unix(req.Until, 0),
})
periodProducer := NewPeriodProducer(PeriodProducerOptions{
MetricID: req.MetricID,
ParsePeriodLayout: parsePeriodLayout,
Location: s.location,
Until: req.Until,
Corrections: corrections,
Rows: rows,
Tx: tx,
})
result, err = SplitByPeriods(periodProducer, periodCalculator)
if err != nil {
return
}
return result, nil
}
type RangeTotal struct {
Since int64 `json:"since"` // реальная граница
Until int64 `json:"until"` // реальная граница
StartValue float64 `json:"startValue"` // на конец периода
EndValue float64 `json:"endValue"` // на конец периода
}
type getCumulativeTotalFilter struct {
MetricID int64
Since int64
Until int64
LastDayOfMonth int
FirstHourOfDay int
}
func (s *MeasureQueryEngine) getCumulativeTotal(req getCumulativeTotalFilter) (_ *RangeTotal, err error) {
measures, err := s.listAggregatedCumulativeMeasures(AggregatedMeasuresFilter{
MetricID: req.MetricID,
Since: req.Since,
Until: req.Until,
GroupBy: ByDay,
LastDayOfMonth: req.LastDayOfMonth,
FirstHourOfDay: req.FirstHourOfDay,
})
if err != nil {
return
}
if len(measures) == 0 {
return
}
//pretty.PPrintln("TOTAL", measures)
first := measures[0]
last := measures[len(measures)-1]
return &RangeTotal{
Since: first.Since,
Until: last.Until,
StartValue: first.Values[0] - first.Values[1], // value on period end - period total
EndValue: last.Values[0],
}, nil
}
/*
// GetCumulativeTotal - фикс, добавить опции FirstHourInDay, IsFindPeriodEndsInFuture
func (s *MeasureQueryEngine) getCumulativeTotal(req getCumulativeTotalFilter) (_ *RangeTotal, err error) {
tx, err := s.db.Driver().Begin()
if err != nil {
return
}
defer tx.Rollback()
var (
firstTm, lastTm, futureTm int64
firstValue, lastValue, futureValue float64
foundInPast, foundInFuture, foundFirstInRange, foundLastInRange bool
)
// Поиск первого показания
// Заглядываем в прошлое на 3 минуты
firstTm, firstValue, foundInPast, err = s.findLastInRange(tx, req.MetricID, req.Since-3*60, req.Since)
if err != nil {
return
}
if !foundInPast {
//
firstTm, firstValue, foundFirstInRange, err = s.findFirstInRange(tx, req.MetricID, req.Since, req.Until)
if err != nil {
return
}
if !foundFirstInRange {
// Начало периода не найдено - значит нет показаний для указанного периода
return
}
}
// Поиск последнего показания
lastTm, lastValue, foundLastInRange, err = s.findLastInRange(tx, req.MetricID, req.Since, req.Until)
if err != nil {
return
}
if req.CanPeriodEndsInFuture {
// Если можем искать конец в периода в будущем
if foundLastInRange {
if lastTm > (req.Until - 3*60) {
// Если lastTm в последние 3 минуты периода - период корректно завершен
return &RangeTotal{
Since: firstTm,
Until: lastTm,
Value: lastValue,
Total: lastValue - firstValue,
}, nil
}
}
// Период не завершен в последние 3 минуты периода, поэтому ищем в будущем
futureTm, futureValue, foundInFuture, err = s.findFirstAfter(tx, req.MetricID, req.Until)
if err != nil {
return
}
if foundInFuture {
return &RangeTotal{
Since: firstTm,
Until: futureTm,
Value: futureValue,
Total: futureValue - firstValue,
}, nil
} else {
// Супер важная проверка, ибо возможна ситуация, когда когда нашли начало
// текущего периода в конце предыдущего (foundInPast). А это показание было
// последним в БД.
if foundLastInRange {
// Последнее показание внутри периода нашли, неважно когда оно пришло - нам
// подходит.
return &RangeTotal{
Since: firstTm,
Until: lastTm,
Value: lastValue,
Total: lastValue - firstValue,
}, nil
}
// Последнее показание внутри периода не найдено, а значит их не было вообще.
// Просто выходим
return
}
} else {
// В будущем искать нельзя
// Супер важная проверка, ибо возможна ситуация, когда когда нашли начало
// текущего периода в конце предыдущего (foundInPast). А это показание было
// последним в БД.
if foundLastInRange {
// Последнее показание внутри периода нашли, неважно когда оно пришло - нам
// подходит.
return &RangeTotal{
Since: firstTm,
Until: lastTm,
Value: lastValue,
Total: lastValue - firstValue,
}, nil
}
// Последнее показание внутри периода не найдено, а значит их не было вообще.
// Просто выходим
return
}
}
*/
func (s *MeasureQueryEngine) findLastInRangeTx(tx *sql.Tx, metricID int64, since, until int64) (tm int64, value float64, found bool, err error) {
err = tx.QueryRow(`
SELECT tm, value, true
FROM f64
WHERE metricID=? AND tm >= ? AND tm < ?
ORDER BY tm DESC
LIMIT 1`,
metricID, since, until).Scan(&tm, &value, &found)
if err != nil {
if err != sql.ErrNoRows {
return
}
// Нет записей
err = nil
}
return
}
func (s *MeasureQueryEngine) findFirstInRangeTx(tx *sql.Tx, metricID int64, since, until int64) (tm int64, value float64, found bool, err error) {
err = tx.QueryRow(`
SELECT tm, value, true
FROM f64
WHERE metricID=? AND tm >= ? AND tm < ?
ORDER BY tm ASC
LIMIT 1`,
metricID, since, until).Scan(&tm, &value, &found)
if err != nil {
if err != sql.ErrNoRows {
return
}
// Нет записей
err = nil
}
return
}
func (s *MeasureQueryEngine) findFirstAfterTx(tx *sql.Tx, metricID int64, since int64) (tm int64, value float64, found bool, err error) {
err = tx.QueryRow(`
SELECT tm, value, true
FROM f64
WHERE metricID=? AND tm >= ?
ORDER BY tm ASC
LIMIT 1`,
metricID, since).Scan(&tm, &value, &found)
if err != nil {
if err != sql.ErrNoRows {
return
}
// Нет записей
err = nil
}
return
}