Files
qb/mqe/cumulative.go

586 lines
16 KiB
Go
Raw Normal View History

2026-06-19 07:25:46 +03:00
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
}