This commit is contained in:
2026-06-19 07:25:46 +03:00
parent 06823ee547
commit 2d73ce1727
52 changed files with 5173 additions and 547 deletions

340
mqe/aggregated.go Normal file
View File

@@ -0,0 +1,340 @@
package mqe
import (
"fmt"
"strings"
"sync"
"time"
"gordenko.dev/dima/qb/timeutil"
)
type AggregatedMeasure struct {
Period int64 `json:"p"`
Since int64 `json:"s"`
Until int64 `json:"u"`
// для cumulative метрики 2 значения - value и total
// для instant метрики от 1 до 3 значений - min, max, avg
Values []float64 `json:"v"`
}
type AggregatedFilter struct {
Since time.Time
Until time.Time
GroupBy GroupBy
LastDayOfMonth int // например, конец месяца 25 число
FirstHourOfDay int // например день начинается в 6:00
InstantMetrics []InstantMetricAndFuncs
CumulativeMetrics []int64
}
type InstantMetricAndFuncs struct {
MetricID int64
AggregateFuncs byte
}
func (s *MeasureQueryEngine) SelectAggregatedData(req AggregatedFilter) (resultMap map[int64][]AggregatedMeasure, err error) {
since := req.Since
until := req.Until
// Валидация
switch req.GroupBy {
case ByHour, ByDay, ByMonth:
// pass
default:
err = fix.Field(WrongValue, "groupBy")
return
}
if req.FirstHourOfDay < 0 || req.FirstHourOfDay > 23 {
err = fix.Field(InvalidFirstHourOfDay, "firstHourOfDay")
return
}
if req.LastDayOfMonth < 0 || req.LastDayOfMonth > 28 {
err = fix.Field(InvalidLastDayOfMonth, "lastDayOfMonth")
return
}
// Корректируем, ВСЕГДА на последнюю секунду суток
//until = timeutil.LastSecondInPeriod(until, "d")
// fmt.Printf(`SelectAggregatedData: {
// Since: %s
// Until: %s
// GroupBy: %s
// FirstHourOfDay: %d
// LastDayOfMonth: %d
// }
// `, since, until, req.GroupBy, req.FirstHourOfDay, req.LastDayOfMonth)
// FIX проверить как добавляются часы в DST часовых поясах
// Для req.FirstHourOfDay=4 since должен быть 4:00:00, until должен быть
// 3:59:59 следующего дня
if req.FirstHourOfDay > 0 {
since = since.Add(time.Duration(req.FirstHourOfDay) * time.Hour)
until = until.Add(time.Duration(req.FirstHourOfDay) * time.Hour)
}
sinceUnixtime := since.Unix()
untilUnixtime := until.Unix()
if sinceUnixtime >= untilUnixtime {
err = fix.Error(WrongDateRange)
return
}
var (
cumulativeTasks []*AggregatedCumulativeTask
instantTasks []*AggregatedInstantTask
wg = new(sync.WaitGroup)
)
// Важно вирутальная метрика зависит от реальных, которые к объекту могут быть
// привязаны или нет. Если нет - не возвращать.
resultMap = make(map[int64][]AggregatedMeasure)
if len(req.CumulativeMetrics) > 0 {
for _, metricID := range req.CumulativeMetrics {
task := &AggregatedCumulativeTask{
In: AggregatedMeasuresFilter{
MetricID: metricID,
Since: sinceUnixtime,
Until: untilUnixtime,
GroupBy: req.GroupBy,
LastDayOfMonth: req.LastDayOfMonth,
FirstHourOfDay: req.FirstHourOfDay,
},
WaitGroup: wg,
}
wg.Add(1)
s.aggregatedCumulativeCh <- task
cumulativeTasks = append(cumulativeTasks, task)
}
}
if len(req.InstantMetrics) > 0 {
for _, metric := range req.InstantMetrics {
task := &AggregatedInstantTask{
In: AggregatedInstantMeasuresIn{
MetricID: metric.MetricID,
Since: sinceUnixtime,
Until: untilUnixtime,
GroupBy: req.GroupBy,
LastDayOfMonth: req.LastDayOfMonth,
FirstHourOfDay: req.FirstHourOfDay,
Flags: metric.AggregateFuncs, // ФЛАГИ !!!
},
WaitGroup: wg,
}
wg.Add(1)
s.aggregatedInstantCh <- task
instantTasks = append(instantTasks, task)
}
}
wg.Wait()
for _, task := range cumulativeTasks {
if task.Err != nil {
err = fmt.Errorf("get data for the metric %d: %s", task.In.MetricID, task.Err)
return
}
resultMap[task.In.MetricID] = task.Result
}
for _, task := range instantTasks {
if task.Err != nil {
err = fmt.Errorf("get data for the metric %d: %s", task.In.MetricID, task.Err)
return
}
resultMap[task.In.MetricID] = task.Result
}
return
}
type IndividualAggregatedFilter struct {
Since time.Time
Until time.Time
GroupBy GroupBy
InstantMetrics []IndividualInstantMetric
CumulativeMetrics []IndividualCumulativeMetric
}
type IndividualInstantMetric struct {
MetricID int64
AggregateFuncs byte
LastDayOfMonth int // например, конец месяца 25 число
FirstHourOfDay int // например день начинается в 6:00
}
type IndividualCumulativeMetric struct {
MetricID int64
LastDayOfMonth int // например, конец месяца 25 число
FirstHourOfDay int // например день начинается в 6:00
}
func (s *MeasureQueryEngine) SelectIndividualAggregatedData(req IndividualAggregatedFilter) (resultMap map[int64][]AggregatedMeasure, err error) {
if req.Since.IsZero() {
err = fmt.Errorf("zero Since")
return
}
if req.Until.IsZero() {
err = fmt.Errorf("zero Until")
return
}
if req.Since.After(req.Until) {
err = fmt.Errorf("wrong time range: since %s after until %s", req.Since, req.Until)
return
}
// Корректируем, ВСЕГДА на первую и последнюю секунду суток
req.Since = timeutil.FirstSecondInPeriod(req.Since, "d")
req.Until = timeutil.LastSecondInPeriod(req.Until, "d")
// Валидация
switch req.GroupBy {
case ByHour, ByDay, ByMonth:
// pass
default:
err = fix.Field(WrongValue, "groupBy")
return
}
// fmt.Printf(`SelectIndividualAggregatedData: {
// Since: %s
// Until: %s
// GroupBy: %s
// }
// `, req.Since, req.Until, req.GroupBy)
// Важно вирутальная метрика зависит от реальных, которые к объекту могут быть
// привязаны или нет. Если нет - не возвращать.
resultMap = make(map[int64][]AggregatedMeasure)
var (
unexpectedErrors []error
errorMutex sync.Mutex
wg sync.WaitGroup
)
wg.Add(len(req.CumulativeMetrics) + len(req.InstantMetrics))
if len(req.CumulativeMetrics) > 0 {
for _, m := range req.CumulativeMetrics {
go func(metric IndividualCumulativeMetric) {
if metric.FirstHourOfDay < 0 || metric.FirstHourOfDay > 23 {
err = fix.Field(InvalidFirstHourOfDay, "firstHourOfDay")
return
}
if metric.LastDayOfMonth < 0 || metric.LastDayOfMonth > 28 {
err = fix.Field(InvalidLastDayOfMonth, "lastDayOfMonth")
return
}
since := req.Since
until := req.Until
// FIX проверить как добавляются часы в DST часовых поясах
// Для req.FirstHourOfDay=4 since должен быть 4:00:00, until должен быть
// 3:59:59 следующего дня
if metric.FirstHourOfDay > 0 {
since = req.Since.Add(time.Duration(metric.FirstHourOfDay) * time.Hour)
until = req.Until.Add(time.Duration(metric.FirstHourOfDay) * time.Hour)
}
result, err := s.listAggregatedCumulativeMeasures(AggregatedMeasuresFilter{
MetricID: metric.MetricID,
Since: since.Unix(),
Until: until.Unix(),
GroupBy: req.GroupBy,
LastDayOfMonth: metric.LastDayOfMonth,
FirstHourOfDay: metric.FirstHourOfDay,
})
if err != nil {
err = fmt.Errorf("ListAggregatedCumulativeMeasures: %s", err)
errorMutex.Lock()
unexpectedErrors = append(unexpectedErrors, err)
errorMutex.Unlock()
} else {
errorMutex.Lock()
resultMap[metric.MetricID] = result
errorMutex.Unlock()
}
wg.Done()
}(m)
}
}
if len(req.InstantMetrics) > 0 {
for _, m := range req.InstantMetrics {
go func(metric IndividualInstantMetric) {
if metric.FirstHourOfDay < 0 || metric.FirstHourOfDay > 23 {
err = fix.Field(InvalidFirstHourOfDay, "firstHourOfDay")
return
}
if metric.LastDayOfMonth < 0 || metric.LastDayOfMonth > 28 {
err = fix.Field(InvalidLastDayOfMonth, "lastDayOfMonth")
return
}
since := req.Since
until := req.Until
// FIX проверить как добавляются часы в DST часовых поясах
// Для req.FirstHourOfDay=4 since должен быть 4:00:00, until должен быть
// 3:59:59 следующего дня
if metric.FirstHourOfDay > 0 {
since = req.Since.Add(time.Duration(metric.FirstHourOfDay) * time.Hour)
until = req.Until.Add(time.Duration(metric.FirstHourOfDay) * time.Hour)
}
result, err := s.listAggregatedInstantMeasures(AggregatedInstantMeasuresIn{
MetricID: metric.MetricID,
Since: since.Unix(),
Until: until.Unix(),
GroupBy: req.GroupBy,
LastDayOfMonth: metric.LastDayOfMonth,
FirstHourOfDay: metric.FirstHourOfDay,
Flags: metric.AggregateFuncs, // ФЛАГИ !!!
})
if err != nil {
err = fmt.Errorf("listAggregatedInstantMeasures: %s", err)
errorMutex.Lock()
unexpectedErrors = append(unexpectedErrors, err)
errorMutex.Unlock()
} else {
errorMutex.Lock()
resultMap[metric.MetricID] = result
errorMutex.Unlock()
}
wg.Done()
}(m)
}
}
wg.Wait()
// Проверяем что все запросы завершились удачно
if len(unexpectedErrors) > 0 {
// Нумеруем сообщения об ошибках и склеиваем в одно большое сообщение
var errorStrings []string
for idx, err := range unexpectedErrors {
errorStrings = append(errorStrings, fmt.Sprintf("\n#%d %s", idx+1, err))
}
err = fmt.Errorf("%d errors occured:%s", len(unexpectedErrors),
strings.Join(errorStrings, ""))
return
}
return
}

585
mqe/cumulative.go Normal file
View File

@@ -0,0 +1,585 @@
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
}

29
mqe/fix.go Normal file
View File

@@ -0,0 +1,29 @@
package mqe
import (
"gordenko.dev/dima/fixme"
)
const (
EmptyValue = "EMPTY_VALUE"
BusyName = "BUSY_NAME"
WrongValue = "WRONG_VALUE"
WrongDate = "WRONG_DATE"
WrongDateRange = "WRONG_DATE_RANGE"
InvalidFirstHourOfDay = "WRONG_FIRST_HOUR_OF_DAY"
InvalidLastDayOfMonth = "WRONG_LAST_DAY_OF_MONTH"
)
var (
errorMessages = map[fixme.Code]string{
EmptyValue: "порожнє значення неприпустимо",
BusyName: "имя уже используется",
WrongValue: "неправильне значення",
WrongDate: "некоректна дата",
WrongDateRange: "некоректний діапазон",
InvalidFirstHourOfDay: "корректное значение от 0 до 23",
InvalidLastDayOfMonth: "корректное значение от 0 до 28",
}
fix = fixme.New(errorMessages)
)

158
mqe/helpers.go Normal file
View File

@@ -0,0 +1,158 @@
package mqe
import (
"fmt"
)
func getPeriodDateTime(groupBy GroupBy, lastDayOfMonth int, firstHourOfDay int) (dt string, err error) {
//fmt.Printf("getPeriodDateTime: %s, lastDay: %d, firstHour: %d\n", groupBy, lastDayOfMonth, firstHourOfDay)
// Если FirstHourOfDay и LastDayOfMonth равны 0 - модифицировать дату не нужно
dt = `FROM_UNIXTIME(tm)`
switch groupBy {
case ByDay:
if firstHourOfDay > 0 {
dt = fmt.Sprintf(`
IF (HOUR(FROM_UNIXTIME(tm)) >= %d,
FROM_UNIXTIME(tm),
DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY)
)`,
firstHourOfDay)
}
case ByMonth:
if lastDayOfMonth > 0 {
if lastDayOfMonth > 28 {
// На всякий случай добавляем защиту от дурака, ибо модификация даты
// в запросе (+1 месяц) для 29, 30 и 31 чисел сделает запрос неверным.
err = fmt.Errorf("LastDayOfMonth: %d, max allowed value is 28", lastDayOfMonth)
return
}
if firstHourOfDay > 0 {
// В псевдокоде модификация времени показания по корректный период
// выглядит так:
//
// if hour >= FirstHourOfDay {
// if day <= LastDayOfMonth {
// return tm
// } else {
// return tm + 1 month
// }
// } else {
// if (day - 1) <= LastDayOfMonth {
// return tm - 1 day
// } else {
// return tm - 1 day + 1 month
// }
// }
dt = fmt.Sprintf(`
IF (HOUR(FROM_UNIXTIME(tm)) >= %d,
IF (DAYOFMONTH(FROM_UNIXTIME(tm)) <= %d,
FROM_UNIXTIME(tm),
DATE_ADD(FROM_UNIXTIME(tm), INTERVAL 1 MONTH)
),
IF (DAYOFMONTH(DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY)) <= %d,
DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY),
DATE_ADD(DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY), INTERVAL 1 MONTH)
)
)`,
firstHourOfDay, lastDayOfMonth, lastDayOfMonth)
} else {
dt = fmt.Sprintf(`
IF (DAYOFMONTH(FROM_UNIXTIME(tm)) <= %d,
FROM_UNIXTIME(tm),
DATE_ADD(FROM_UNIXTIME(tm), INTERVAL 1 MONTH)
)`,
lastDayOfMonth)
}
} else {
if firstHourOfDay > 0 {
dt = fmt.Sprintf(`
IF (HOUR(FROM_UNIXTIME(tm)) >= %d,
FROM_UNIXTIME(tm),
DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY)
)`,
firstHourOfDay)
}
}
}
return
}
func getDateTimeFacingFHD(firstHourOfDay int) (dt string, err error) {
if firstHourOfDay > 0 {
dt = fmt.Sprintf(`
IF (HOUR(FROM_UNIXTIME(tm)) >= %d,
FROM_UNIXTIME(tm),
DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY)
)`,
firstHourOfDay)
} else {
// Если FirstHourOfDay = 0 - модифицировать дату не нужно
dt = "FROM_UNIXTIME(tm)"
}
return
}
func getDateTimeFacingFHDAndLDM(firstHourOfDay int, lastDayOfMonth int) (dt string, err error) {
// Если FirstHourOfDay и LastDayOfMonth равны 0 - модифицировать дату не нужно
dt = "FROM_UNIXTIME(tm)"
if lastDayOfMonth > 0 {
if lastDayOfMonth > 28 {
// На всякий случай добавляем защиту от дурака, ибо модификация даты
// в запросе (+1 месяц) для 29, 30 и 31 чисел сделает запрос неверным.
err = fmt.Errorf("LastDayOfMonth: %d, max allowed value is 28", lastDayOfMonth)
return
}
if firstHourOfDay > 0 {
// В псевдокоде модификация времени показания по корректный период
// выглядит так:
//
// if hour >= FirstHourOfDay {
// if day <= LastDayOfMonth {
// return tm
// } else {
// return tm + 1 month
// }
// } else {
// if (day - 1) <= LastDayOfMonth {
// return tm - 1 day
// } else {
// return tm - 1 day + 1 month
// }
// }
dt = fmt.Sprintf(`
IF (HOUR(FROM_UNIXTIME(tm)) >= %d,
IF (DAYOFMONTH(FROM_UNIXTIME(tm)) <= %d,
FROM_UNIXTIME(tm),
DATE_ADD(FROM_UNIXTIME(tm), INTERVAL 1 MONTH)
),
IF (DAYOFMONTH(DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY)) <= %d,
DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY),
DATE_ADD(DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY), INTERVAL 1 MONTH)
)
)`,
firstHourOfDay, lastDayOfMonth, lastDayOfMonth)
} else {
dt = fmt.Sprintf(`
IF (DAYOFMONTH(FROM_UNIXTIME(tm)) <= %d,
FROM_UNIXTIME(tm),
DATE_ADD(FROM_UNIXTIME(tm), INTERVAL 1 MONTH)
)`,
lastDayOfMonth)
}
} else {
if firstHourOfDay > 0 {
dt = fmt.Sprintf(`
IF (HOUR(FROM_UNIXTIME(tm)) >= %d,
FROM_UNIXTIME(tm),
DATE_SUB(FROM_UNIXTIME(tm), INTERVAL 1 DAY)
)`,
firstHourOfDay)
}
}
return
}

320
mqe/index.go Normal file
View File

@@ -0,0 +1,320 @@
package mqe
import (
"fmt"
"sort"
"strconv"
)
type GetYearIndexReq struct {
Since int64 `json:"since"` // из фильтр-формы
Until int64 `json:"until"` // из фильтр-формы
LastDayOfMonth int `json:"lastDayOfMonth"` // например, конец месяца 25 число
FirstHourOfDay int `json:"firstHourOfDay"` // например день начинается в 6:00
MetricIDs []int64 `json:"metricIDs"`
}
func (s *MeasureQueryEngine) GetYearIndex(req GetYearIndexReq) (years []int, err error) {
if len(req.MetricIDs) == 0 {
return
}
var (
lte, gte string
args = []any{
req.MetricIDs,
}
)
if req.Since > 0 {
lte = " AND tm >= ?"
args = append(args, req.Since)
}
if req.Until > 0 {
gte = " AND tm <= ?"
args = append(args, req.Until)
}
q := fmt.Sprintf(`
SELECT YEAR(FROM_UNIXTIME(tm)) as y
FROM f64
WHERE metricID IN (?)%s%s
GROUP BY y`,
lte, gte)
// fmt.Println(q)
// fmt.Printf("%v\n", args)
err = s.db.ListQuery(&years, q, args...)
if err != nil {
return
}
sort.Ints(years)
return
}
type GetMonthIndexReq struct {
Since int64 `json:"since"` // из фильтр-формы
Until int64 `json:"until"` // из фильтр-формы
LastDayOfMonth int `json:"lastDayOfMonth"` // например, конец месяца 25 число
FirstHourOfDay int `json:"firstHourOfDay"` // например день начинается в 6:00
MetricIDs []int64 `json:"metricIDs"`
}
type YearMonth struct {
Year int `json:"year"`
Months []int `json:"months"`
}
func (s *MeasureQueryEngine) GetMonthIndex(req GetMonthIndexReq) (years []YearMonth, err error) {
if len(req.MetricIDs) == 0 {
return
}
dt, err := getDateTimeFacingFHDAndLDM(req.FirstHourOfDay, req.LastDayOfMonth)
if err != nil {
return
}
var (
lte, gte string
args = []any{
req.MetricIDs,
}
)
if req.Since > 0 {
lte = " AND tm >= ?"
args = append(args, req.Since)
}
if req.Until > 0 {
gte = " AND tm <= ?"
args = append(args, req.Until)
}
// ВАЖНО!
// Не сортируем средствами MySQL чтобы не создавать временный файл
query := fmt.Sprintf(`
SELECT DATE_FORMAT(%s, '%s') as p
FROM f64
WHERE metricID IN (?)%s%s
GROUP BY p`,
dt, "%Y%m", lte, gte)
var (
temp []string
yms []int
)
err = s.db.ListQuery(&temp, query, args...)
if err != nil {
return
}
for _, str := range temp {
var ym int
ym, err = strconv.Atoi(str)
if err != nil {
return
}
yms = append(yms, ym)
}
return IndexYms(yms), nil
}
type GetDayIndexReq struct {
Since int64 `json:"since"` // из фильтр-формы
Until int64 `json:"until"` // из фильтр-формы
FirstHourOfDay int `json:"firstHourOfDay"` // например день начинается в 6:00
MetricIDs []int64 `json:"metricIDs"`
}
type YearMonthDay struct {
Year int `json:"year"`
Months []MonthDay `json:"months"`
}
type MonthDay struct {
Month int `json:"month"`
Days []int `json:"days"`
}
func (s *MeasureQueryEngine) GetDayIndex(req GetDayIndexReq) (years []YearMonthDay, err error) {
if len(req.MetricIDs) == 0 {
return
}
dt, err := getDateTimeFacingFHD(req.FirstHourOfDay)
if err != nil {
return
}
var (
lte, gte string
args = []any{
req.MetricIDs,
}
)
if req.Since > 0 {
lte = " AND tm >= ?"
args = append(args, req.Since)
}
if req.Until > 0 {
gte = " AND tm <= ?"
args = append(args, req.Until)
}
// ВАЖНО!
// Не сортируем средствами MySQL чтобы не создавать временный файл
query := fmt.Sprintf(`
SELECT DATE_FORMAT(%s, '%s') as p
FROM f64
WHERE metricID IN(?)%s%s
GROUP BY p`,
dt, "%Y%m%d", lte, gte)
//fmt.Printf("%s\n", query)
var (
temp []string
ymds []int
)
err = s.db.ListQuery(&temp, query, args...)
if err != nil {
return
}
//pretty.PPrintln("temp", temp)
for _, str := range temp {
var ymd int
ymd, err = strconv.Atoi(str)
if err != nil {
return
}
ymds = append(ymds, ymd)
}
return IndexYmds(ymds), nil
}
func IndexYmds(ymds []int) (years []YearMonthDay) {
if len(ymds) == 0 {
return
}
sort.Ints(ymds)
var (
months []MonthDay
days []int
year, month, day int
)
for _, ymd := range ymds {
y := ymd / 10000
m := (ymd % 10000) / 100
d := ymd % 100
if year == 0 {
// инициализация
year = y
month = m
day = d
} else {
if y != year {
days = append(days, day)
months = append(months, MonthDay{
Month: month,
Days: days,
})
years = append(years, YearMonthDay{
Year: year,
Months: months,
})
year = y
month = m
day = d
months = nil
days = nil
} else if m != month {
days = append(days, day)
months = append(months, MonthDay{
Month: month,
Days: days,
})
month = m
day = d
days = nil
} else if d != day {
days = append(days, day)
day = d
}
}
}
days = append(days, day)
months = append(months, MonthDay{
Month: month,
Days: days,
})
years = append(years, YearMonthDay{
Year: year,
Months: months,
})
return
}
func IndexYms(yms []int) (years []YearMonth) {
if len(yms) == 0 {
return
}
sort.Ints(yms)
var (
months []int
year, month int
)
for _, ym := range yms {
y := ym / 100
m := ym % 100
if year == 0 {
// инициализация
year = y
month = m
} else {
if y != year {
months = append(months, month)
years = append(years, YearMonth{
Year: year,
Months: months,
})
year = y
month = m
months = nil
} else if m != month {
months = append(months, month)
month = m
}
}
}
months = append(months, month)
years = append(years, YearMonth{
Year: year,
Months: months,
})
return
}
//SELECT DATE_FORMAT(FROM_UNIXTIME(tm), '%Y%m%d') as p FROM f64 WHERE metricID IN (50) AND tm >= 1700863200 AND tm <= 1700949599 GROUP BY p;

39
mqe/index_test.go Normal file
View File

@@ -0,0 +1,39 @@
package mqe
import (
"testing"
"gordenko.dev/dima/pretty"
)
func TestDayIndex(t *testing.T) {
index := IndexYmds([]int{
20221201,
20221202,
20230110,
20230210,
20230330,
20230331,
20240101,
20240102,
20240103,
})
pretty.Println(index)
}
func TestMonthIndex(t *testing.T) {
index := IndexYms([]int{
202211,
202212,
202301,
202302,
202303,
202304,
202404,
202405,
202406,
})
pretty.Println(index)
}

226
mqe/instant.go Normal file
View File

@@ -0,0 +1,226 @@
package mqe
import (
"database/sql"
"fmt"
"time"
)
type CurrentValue struct {
MetricID uint32
Time int64 `json:"t"`
Value float64 `json:"v"`
}
func (s *MeasureQueryEngine) ListCurrentValues(metricIDs []uint32) (result []CurrentValue, err error) {
driver := s.db.Driver()
for _, metricID := range metricIDs {
var rows *sql.Rows
rows, err = driver.Query(`
SELECT tm, value
FROM f64
WHERE metricID=?
ORDER BY tm DESC
LIMIT 1`,
metricID)
if err != nil {
if err == sql.ErrNoRows {
err = nil
}
return
}
for rows.Next() {
var (
tm int64
value float64
)
err = rows.Scan(&tm, &value)
if err != nil {
return
}
result = append(result, CurrentValue{
MetricID: metricID,
Time: tm,
Value: value,
})
}
if err = rows.Err(); err != nil {
return
}
rows.Close()
}
return
}
// ListInstantMeasures - cписок показаний мгновенных метрик (Температура, Давление, Расход)
// за за интервал без группировки
func (s *MeasureQueryEngine) listInstantMeasures(req MetricMeasuresFilter) (_ []Measure, err error) {
rows, err := s.db.Driver().Query(`
SELECT tm, value
FROM f64
WHERE metricID=? AND tm BETWEEN ? AND ?
ORDER BY tm ASC`,
req.MetricID, req.Since, req.Until)
if err != nil {
if err == sql.ErrNoRows {
err = nil
}
return
}
defer rows.Close()
var result []Measure
for rows.Next() {
var (
tm int64
value float64
)
err = rows.Scan(&tm, &value)
if err != nil {
return
}
result = append(result, Measure{
Time: tm,
Values: []float64{
value,
},
})
}
if err = rows.Err(); err != nil {
return
}
return result, nil
}
type AggregatedInstantMeasuresIn struct {
MetricID int64 `json:"metricID"`
Since int64 `json:"since"`
Until int64 `json:"until"`
GroupBy GroupBy `json:"groupBy"`
Flags byte `json:"flags"` // из настроек по умолчанию либо из запроса
LastDayOfMonth int `json:"lastDayOfMonth"` // например, конец месяца 25 число
FirstHourOfDay int `json:"firstHourOfDay"` // например день начинается в 6:00
}
// Заполняет поля Period и Values (от 1 до 3 значений) в структуре AggregatedMeasure
func (s *MeasureQueryEngine) listAggregatedInstantMeasures(req AggregatedInstantMeasuresIn) (_ []AggregatedMeasure, err error) {
var (
groupByFormat string
parsePeriodLayout string
result []AggregatedMeasure
)
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
}
valuesQty := 0
values := ""
if (req.Flags & AggregateMin) == AggregateMin {
values += " MIN(value),"
valuesQty++
}
if (req.Flags & AggregateMax) == AggregateMax {
values += " MAX(value),"
valuesQty++
}
if (req.Flags & AggregateAvg) == AggregateAvg {
values += " AVG(value),"
valuesQty++
}
dt, err := getPeriodDateTime(req.GroupBy, req.LastDayOfMonth, req.FirstHourOfDay)
if err != nil {
return
}
query := fmt.Sprintf(`
SELECT%s DATE_FORMAT(%s, '%s') as periodStr
FROM f64
WHERE metricID=? AND tm BETWEEN ? AND ?
GROUP BY periodStr
ORDER BY periodStr ASC`,
values, dt, groupByFormat)
rows, err := s.db.Driver().Query(query, req.MetricID, req.Since, req.Until)
if err != nil {
if err == sql.ErrNoRows {
err = nil
}
return
}
for rows.Next() {
var (
m = AggregatedMeasure{
Values: make([]float64, valuesQty),
}
periodStr string
period time.Time
valueIdx int
dest []interface{}
)
if (req.Flags & AggregateMin) == AggregateMin {
dest = append(dest, &m.Values[valueIdx])
valueIdx++
}
if (req.Flags & AggregateMax) == AggregateMax {
dest = append(dest, &m.Values[valueIdx])
valueIdx++
}
if (req.Flags & AggregateAvg) == AggregateAvg {
dest = append(dest, &m.Values[valueIdx])
}
dest = append(dest, &periodStr)
err = rows.Scan(dest...)
if err != nil {
return
}
period, err = time.ParseInLocation(parsePeriodLayout, periodStr, s.location)
if err != nil {
err = fmt.Errorf("time.ParseInLocation: %s; layout=%q; str=%q",
err, parsePeriodLayout, periodStr)
return
}
m.Period = period.Unix()
result = append(result, m)
}
if err = rows.Err(); err != nil {
return
}
return result, nil
}

161
mqe/mqe.go Normal file
View File

@@ -0,0 +1,161 @@
package mqe
import (
"sync"
"time"
"gordenko.dev/dima/qx"
)
type GroupBy string
// Для парсинга since и until
const (
NoAggregateFunc byte = 1
AggregateMin byte = 2
AggregateMax byte = 4
AggregateAvg byte = 8
ByHour GroupBy = "h"
ByDay GroupBy = "d"
ByMonth GroupBy = "m"
hourPeriodLayout = "2006010215"
dayPeriodLayout = "20060102"
monthPeriodLayout = "200601"
dateLayout = "2006-01-02"
)
type MetricMeasuresFilter struct {
MetricID int64 `json:"metricID"`
Since int64 `json:"since"` // уже учтен firstHourOfDay
Until int64 `json:"until"` // уже учтен firstHourOfDay
}
type MeasureQueryEngine struct {
db *qx.Db
location *time.Location
cumulativeCh chan *CumulativeTask
instantCh chan *InstantTask
aggregatedCumulativeCh chan *AggregatedCumulativeTask
aggregatedInstantCh chan *AggregatedInstantTask
cumulativeTotalCh chan *CumulativeTotalTask
metricReadingsCh chan *MetricReadingsTask
}
type Options struct {
Db *qx.Db
Location *time.Location
MinWorkers int
QueueSize int
}
func New(opt Options) *MeasureQueryEngine {
if opt.Db == nil {
panic("Db option is required")
}
if opt.Location == nil {
panic("Location option is required")
}
if opt.MinWorkers <= 0 {
panic("MinWorkers option is required")
}
if opt.QueueSize <= 0 {
panic("QueueSize option is required")
}
s := new(MeasureQueryEngine)
s.db = opt.Db
s.location = opt.Location
//
s.cumulativeCh = make(chan *CumulativeTask, opt.QueueSize)
s.instantCh = make(chan *InstantTask, opt.QueueSize)
s.aggregatedCumulativeCh = make(chan *AggregatedCumulativeTask, opt.QueueSize)
s.aggregatedInstantCh = make(chan *AggregatedInstantTask, opt.QueueSize)
s.cumulativeTotalCh = make(chan *CumulativeTotalTask, opt.QueueSize)
s.metricReadingsCh = make(chan *MetricReadingsTask, opt.QueueSize)
//
for i := 0; i < opt.MinWorkers; i++ {
go s.worker()
}
return s
}
func (s *MeasureQueryEngine) worker() {
for {
select {
case task := <-s.cumulativeCh:
task.Result, task.Err = s.listCumulativeMeasures(task.In)
task.WaitGroup.Done()
case task := <-s.instantCh:
task.Result, task.Err = s.listInstantMeasures(task.In)
task.WaitGroup.Done()
case task := <-s.aggregatedCumulativeCh:
task.Result, task.Err = s.listAggregatedCumulativeMeasures(task.In)
task.WaitGroup.Done()
case task := <-s.aggregatedInstantCh:
task.Result, task.Err = s.listAggregatedInstantMeasures(task.In)
task.WaitGroup.Done()
case task := <-s.cumulativeTotalCh:
task.Result, task.Err = s.getCumulativeTotal(task.In)
task.WaitGroup.Done()
case task := <-s.metricReadingsCh:
task.Result, task.Err = s.listMetricReadings(task.In)
task.WaitGroup.Done()
}
}
}
//
type CumulativeTask struct {
In MetricMeasuresFilter
WaitGroup *sync.WaitGroup
Result []Measure
Err error
}
type InstantTask struct {
In MetricMeasuresFilter
WaitGroup *sync.WaitGroup
Result []Measure
Err error
}
type AggregatedCumulativeTask struct {
In AggregatedMeasuresFilter
WaitGroup *sync.WaitGroup
Result []AggregatedMeasure
Err error
}
type AggregatedInstantTask struct {
In AggregatedInstantMeasuresIn
WaitGroup *sync.WaitGroup
Result []AggregatedMeasure
Err error
}
type MetricReadingsTask struct {
In MetricMeasuresFilter
WaitGroup *sync.WaitGroup
Result MetricReadings
Err error
}
type CumulativeTotalTask struct {
In getCumulativeTotalFilter
WaitGroup *sync.WaitGroup
Result *RangeTotal
Err error
}

283
mqe/newreq.go Normal file
View File

@@ -0,0 +1,283 @@
package mqe
// НОВАЯ ВЕРСИЯ
// type MetricsDataFilter2 struct {
// Since time.Time `json:"since"`
// Until time.Time `json:"until"`
// GroupBy string `json:"groupBy"`
// InstantMetrics []InstantMetricFilter // virtualMetricID + aggregateFuncs
// CumulativeMetrics []CumulativeMetricFilter
// }
// type InstantMetricFilter struct {
// MetricID int64
// AggregateFuncs byte
// LastDayOfMonth int // например, конец месяца 25 число
// FirstHourOfDay int // например день начинается в 6:00
// }
// type CumulativeMetricFilter struct {
// MetricID int64
// LastDayOfMonth int // например, конец месяца 25 число
// FirstHourOfDay int // например день начинается в 6:00
// }
// func (s *MeasureQueryEngine) SelectMetricsDataInParallel2(req MetricsDataFilter2) (_ MetricsDataResultMaps, err error) {
// pretty.PPrintln("SelectMetricsDataInParallel2", req)
// // Строгая валидация
// if req.Since.IsZero() {
// err = fmt.Errorf("Since option is required")
// return
// }
// if req.Until.IsZero() {
// err = fmt.Errorf("Until option is required")
// return
// }
// if req.Since.After(req.Until) {
// err = fmt.Errorf("Wrong time range: Since %d after Until %d", req.Since.Unix(), req.Until.Unix())
// return
// }
// switch req.GroupBy {
// case "h", "d", "m", "":
// // pass
// default:
// err = fmt.Errorf("Wrong groupBy option value: %s", req.GroupBy)
// return
// }
// for _, mf := range req.InstantMetrics {
// if mf.MetricID == 0 {
// err = fmt.Errorf("Empty instant metric's MetricID: %d", mf.MetricID)
// return
// }
// if mf.FirstHourOfDay < 0 || mf.FirstHourOfDay > 23 {
// err = fmt.Errorf("Wrong instant metric's FirstHourOfDay option value: %d", mf.FirstHourOfDay)
// return
// }
// if mf.LastDayOfMonth < 0 || mf.LastDayOfMonth > 28 {
// err = fmt.Errorf("Wrong instant metric's LastDayOfMonth option value: %d", mf.LastDayOfMonth)
// return
// }
// }
// for _, mf := range req.CumulativeMetrics {
// if mf.MetricID == 0 {
// err = fmt.Errorf("Empty cumulative metric's MetricID: %d", mf.MetricID)
// return
// }
// if mf.FirstHourOfDay < 0 || mf.FirstHourOfDay > 23 {
// err = fmt.Errorf("Wrong cumulative metric's FirstHourOfDay option value: %d", mf.FirstHourOfDay)
// return
// }
// if mf.LastDayOfMonth < 0 || mf.LastDayOfMonth > 28 {
// err = fmt.Errorf("Wrong cumulative metric's LastDayOfMonth option value: %d", mf.LastDayOfMonth)
// return
// }
// }
// // Корректируем, ВСЕГДА на первую и последнюю секунду суток
// since := timeutil.FirstSecondInPeriod(req.Since, "d")
// until := timeutil.LastSecondInPeriod(req.Until, "d")
// fmt.Printf(`SelectMetricsDataInParallel2: {
// Since: %s
// Until: %s
// GroupBy: %s
// }
// `, since, until, req.GroupBy)
// // Важно вирутальная метрика зависит от реальных, которые к объекту могут быть
// // привязаны или нет. Если нет - не возвращать.
// var (
// unexpectedErrors []error
// errorMutex sync.Mutex
// wg sync.WaitGroup
// resultMaps = MetricsDataResultMaps{
// AggregatedCumulative: make(map[int64][]CalculatedAggregatedMeasure),
// AggregatedByFuncInstant: make(map[int64][]AggregatedByFuncInstantMeasure),
// Cumulative: make(map[int64][]CalculatedMeasure),
// Instant: make(map[int64][]CalculatedMeasure),
// }
// )
// wg.Add(len(req.CumulativeMetrics) + len(req.InstantMetrics))
// if req.GroupBy != "" {
// // AGGREGATED
// // CUMULATIVE
// for _, metricFilter := range req.CumulativeMetrics {
// go func(mf CumulativeMetricFilter) {
// // У каждой метрики индивидуальные настройки,
// // ибо метрики принадлежат разным объектам!!!
// metricSince := since
// metricUntil := until
// if mf.FirstHourOfDay > 0 {
// metricSince = since.Add(time.Duration(mf.FirstHourOfDay) * time.Hour)
// metricUntil = until.Add(time.Duration(mf.FirstHourOfDay) * time.Hour)
// }
// result, err := s.listAggregatedCumulativeMeasures(AggregatedMeasuresFilter{
// MetricID: mf.MetricID,
// Since: metricSince.Unix(),
// Until: metricUntil.Unix(),
// GroupBy: req.GroupBy,
// LastDayOfMonth: mf.LastDayOfMonth,
// FirstHourOfDay: mf.FirstHourOfDay,
// })
// if err != nil {
// err = fmt.Errorf("ListAggregatedCumulativeMeasures: %s", err)
// errorMutex.Lock()
// unexpectedErrors = append(unexpectedErrors, err)
// errorMutex.Unlock()
// } else {
// errorMutex.Lock()
// resultMaps.AggregatedCumulative[mf.MetricID] = result
// errorMutex.Unlock()
// }
// wg.Done()
// }(metricFilter)
// }
// // INSTANT
// for _, metricFilter := range req.InstantMetrics {
// go func(mf InstantMetricFilter) {
// // У каждой метрики индивидуальные настройки,
// // ибо метрики принадлежат разным объектам!!!
// metricSince := since
// metricUntil := until
// if mf.FirstHourOfDay > 0 {
// metricSince = since.Add(time.Duration(mf.FirstHourOfDay) * time.Hour)
// metricUntil = until.Add(time.Duration(mf.FirstHourOfDay) * time.Hour)
// }
// result, err := s.listAggregatedByFuncsInstantMeasures(AggregatedByFuncInstantMeasuresFilter{
// MetricID: mf.MetricID,
// Since: metricSince.Unix(),
// Until: metricUntil.Unix(),
// GroupBy: req.GroupBy,
// LastDayOfMonth: mf.LastDayOfMonth,
// FirstHourOfDay: mf.FirstHourOfDay,
// Flags: mf.AggregateFuncs, // ФЛАГИ !!!
// })
// if err != nil {
// err = fmt.Errorf("ListAggregatedByFuncInstantMeasures: %s", err)
// errorMutex.Lock()
// unexpectedErrors = append(unexpectedErrors, err)
// errorMutex.Unlock()
// } else {
// errorMutex.Lock()
// resultMaps.AggregatedByFuncInstant[mf.MetricID] = result
// errorMutex.Unlock()
// }
// wg.Done()
// }(metricFilter)
// }
// } else {
// // NON AGGREGATED
// // CUMULATIVE
// for _, metricFilter := range req.CumulativeMetrics {
// go func(mf CumulativeMetricFilter) {
// // У каждой метрики индивидуальные настройки,
// // ибо метрики принадлежат разным объектам!!!
// metricSince := since
// metricUntil := until
// if mf.FirstHourOfDay > 0 {
// metricSince = since.Add(time.Duration(mf.FirstHourOfDay) * time.Hour)
// metricUntil = until.Add(time.Duration(mf.FirstHourOfDay) * time.Hour)
// }
// result, err := s.listCumulativeMeasures(MeasuresFilter{
// MetricID: mf.MetricID,
// Since: metricSince.Unix(),
// Until: metricUntil.Unix(),
// })
// if err != nil {
// err = fmt.Errorf("ListCumulativeMeasures: %s", err)
// errorMutex.Lock()
// unexpectedErrors = append(unexpectedErrors, err)
// errorMutex.Unlock()
// } else {
// errorMutex.Lock()
// resultMaps.Cumulative[mf.MetricID] = result
// errorMutex.Unlock()
// }
// wg.Done()
// }(metricFilter)
// }
// // INSTANT
// for _, metricFilter := range req.InstantMetrics {
// go func(mf InstantMetricFilter) {
// // У каждой метрики индивидуальные настройки,
// // ибо метрики принадлежат разным объектам!!!
// metricSince := since
// metricUntil := until
// if mf.FirstHourOfDay > 0 {
// metricSince = since.Add(time.Duration(mf.FirstHourOfDay) * time.Hour)
// metricUntil = until.Add(time.Duration(mf.FirstHourOfDay) * time.Hour)
// }
// result, err := s.listInstantMeasures(MeasuresFilter{
// MetricID: mf.MetricID,
// Since: metricSince.Unix(),
// Until: metricUntil.Unix(),
// })
// if err != nil {
// err = fmt.Errorf("ListInstantMeasures: %s", err)
// errorMutex.Lock()
// unexpectedErrors = append(unexpectedErrors, err)
// errorMutex.Unlock()
// } else {
// errorMutex.Lock()
// resultMaps.Instant[mf.MetricID] = result
// errorMutex.Unlock()
// }
// wg.Done()
// }(metricFilter)
// }
// }
// wg.Wait()
// // Проверяем что все запросы завершились удачно
// if len(unexpectedErrors) > 0 {
// // Нумеруем сообщения об ошибках и склеиваем в одно большое сообщение
// var errorStrings []string
// for idx, err := range unexpectedErrors {
// errorStrings = append(errorStrings, fmt.Sprintf("\n#%d %s", idx+1, err))
// }
// err = fmt.Errorf("%d errors occured:%s", len(unexpectedErrors),
// strings.Join(errorStrings, ""))
// return
// }
// return resultMaps, nil
// }

172
mqe/period_calculator.go Normal file
View File

@@ -0,0 +1,172 @@
package mqe
import (
"fmt"
"time"
"gordenko.dev/dima/qb/timeutil"
)
type PeriodCalculatorOptions struct {
GroupBy GroupBy
LastDayOfMonth int
FirstHourOfDay int
Since time.Time
Until time.Time
}
func NewPeriodCalculator(opt PeriodCalculatorOptions) PeriodCalculator {
switch opt.GroupBy {
case ByHour, ByDay, ByMonth:
// pass
default:
panic(fmt.Sprintf("bug: unknown groupBy %q", opt.GroupBy))
}
s := PeriodCalculator{
groupBy: opt.GroupBy,
lastDayOfMonth: opt.LastDayOfMonth,
firstHourOfDay: opt.FirstHourOfDay,
}
s.sincePeriod = s.TimeToPeriod(opt.Since)
s.untilPeriod = s.TimeToPeriod(opt.Until)
return s
}
type PeriodCalculator struct {
groupBy GroupBy
lastDayOfMonth int
firstHourOfDay int
sincePeriod time.Time
untilPeriod time.Time
}
func (s PeriodCalculator) IsExtendedSincePeriod(period time.Time) bool {
return period.Before(s.sincePeriod)
}
func (s PeriodCalculator) IsExtendedUntilPeriod(period time.Time) bool {
return period.After(s.untilPeriod)
}
func (s PeriodCalculator) NextPeriod(period time.Time) time.Time {
switch s.groupBy {
case ByHour:
return period.Add(time.Hour)
case ByDay:
return period.AddDate(0, 0, 1)
case ByMonth:
return period.AddDate(0, 1, 0)
default:
panic(fmt.Sprintf("bug: unknown groupBy %q", s.groupBy))
}
}
func (s PeriodCalculator) IsPeriodCorrectEnds(period time.Time, lastMeasureTime int64) bool {
// по умолчанию
tm := timeutil.LastSecondInPeriod(period, string(s.groupBy))
switch s.groupBy {
case ByDay:
if s.firstHourOfDay > 0 {
// Было 23:59, стало - 01:59
tm = tm.Add(time.Duration(s.firstHourOfDay) * time.Hour)
}
case ByMonth:
if s.lastDayOfMonth == 0 {
if s.firstHourOfDay > 0 {
// Было 23:59, стало - 01:59
tm = tm.Add(time.Duration(s.firstHourOfDay) * time.Hour)
}
} else {
if s.firstHourOfDay > 0 {
// last day 28, first hour 2 превращается в day 29 01:59:59
tm = time.Date(
tm.Year(),
tm.Month(),
s.lastDayOfMonth+1,
s.firstHourOfDay-1,
59,
59,
0,
tm.Location(),
)
} else {
// last day 23:59:59
tm = time.Date(
tm.Year(),
tm.Month(),
s.lastDayOfMonth,
23,
59,
59,
0,
tm.Location(),
)
}
}
}
timestamp := tm.Add(-179 * time.Second).Unix()
if lastMeasureTime >= timestamp && lastMeasureTime <= tm.Unix() {
return true
}
//fmt.Printf("Ends: lastMeasureTime=%s, timetsamp=%s\n", time.Unix(lastMeasureTime, 0), time.Unix(timestamp, 0))
return false
}
func (s PeriodCalculator) TimeToPeriod(tm time.Time) (period time.Time) {
switch s.groupBy {
case ByDay:
if s.firstHourOfDay > 0 {
if tm.Hour() < s.firstHourOfDay {
tm = tm.AddDate(0, 0, -1)
}
}
case ByMonth:
if s.lastDayOfMonth > 0 {
if s.lastDayOfMonth > 28 {
// На всякий случай добавляем защиту от дурака, ибо модификация даты
// в запросе (+1 месяц) для 29, 30 и 31 чисел сделает запрос неверным.
panic(fmt.Sprintf("LastDayOfMonth: %d, max allowed value is 28", s.lastDayOfMonth))
}
if s.firstHourOfDay > 0 {
if tm.Hour() >= s.firstHourOfDay {
if tm.Day() > s.lastDayOfMonth {
// add 1 month
tm = tm.AddDate(0, 1, 0)
}
} else {
// firstHourOfDay показывает что текущий день еще не наступил,
// поэтому day - 1
if (tm.Day() - 1) <= s.lastDayOfMonth {
tm = tm.AddDate(0, 0, -1)
} else {
// - 1 day, + 1 month
tm = tm.AddDate(0, 0, -1).AddDate(0, 1, 0)
}
}
} else {
if tm.Day() > s.lastDayOfMonth {
tm = tm.AddDate(0, 1, 0)
}
}
} else {
if s.firstHourOfDay > 0 {
if tm.Hour() < s.firstHourOfDay {
tm = tm.AddDate(0, 0, -1)
}
}
}
}
return timeutil.FirstSecondInPeriod(tm, string(s.groupBy))
}

168
mqe/period_producers.go Normal file
View File

@@ -0,0 +1,168 @@
package mqe
import (
"database/sql"
"fmt"
"time"
)
type periodProducer interface {
Next() (time.Time, PeriodBound, PeriodBound, bool, error)
Last() (PeriodBound, bool, error)
}
type PeriodBound struct {
Time int64
Value float64
}
type PeriodProducer struct {
metricID int64
parsePeriodLayout string
location *time.Location
until int64
corrections []_f64Correction
rows *sql.Rows
tx *sql.Tx
}
type PeriodProducerOptions struct {
MetricID int64
ParsePeriodLayout string
Location *time.Location
Until int64
Corrections []_f64Correction
Rows *sql.Rows
Tx *sql.Tx
}
func NewPeriodProducer(opt PeriodProducerOptions) *PeriodProducer {
if opt.Location == nil {
panic("Location option is required")
}
if opt.MetricID == 0 {
panic("MetricID option is required")
}
if opt.ParsePeriodLayout == "" {
panic("ParsePeriodLayout option is required")
}
if opt.Until == 0 {
panic("Until option is required")
}
if opt.Rows == nil {
panic("Rows option is required")
}
if opt.Tx == nil {
panic("Tx option is required")
}
s := new(PeriodProducer)
s.metricID = opt.MetricID
s.parsePeriodLayout = opt.ParsePeriodLayout
s.location = opt.Location
s.until = opt.Until
s.corrections = opt.Corrections
s.rows = opt.Rows
s.tx = opt.Tx
return s
}
func (s *PeriodProducer) Next() (period time.Time, first PeriodBound, last PeriodBound, found bool, err error) {
var frameStr string
hasNext := s.rows.Next()
if hasNext {
err = s.rows.Scan(&frameStr, &first.Time, &first.Value, &last.Time, &last.Value)
if err != nil {
return
}
first.Value = applyCorrectionsToMeasure(s.corrections, first.Time, first.Value)
last.Value = applyCorrectionsToMeasure(s.corrections, last.Time, last.Value)
period, err = time.ParseInLocation(s.parsePeriodLayout, frameStr, s.location)
if err != nil {
err = fmt.Errorf("time.ParseInLocation: %s; layout=%q; str=%q",
err, s.parsePeriodLayout, frameStr)
return
}
found = true
} else {
err = s.rows.Err()
}
return
}
func (s *PeriodProducer) Last() (next PeriodBound, found bool, err error) {
// Находим следующее показание метрики.
err = s.tx.QueryRow(`
SELECT tm, value, true
FROM f64
WHERE metricID=? AND tm > ?
ORDER BY tm ASC
LIMIT 1`,
s.metricID, s.until).Scan(&next.Time, &next.Value, &found)
if err != nil {
if err != sql.ErrNoRows {
return
}
// Нет записей
err = nil
}
next.Value = applyCorrectionsToMeasure(s.corrections, next.Time, next.Value)
return
}
// ДЛЯ ТЕСТИРОВАНИЯ
type stubPeriod struct {
Period time.Time
First PeriodBound
Last PeriodBound
}
type StubPeriodProducerOptions struct {
Last *PeriodBound
Periods []stubPeriod
}
func NewStubPeriodProducer(opt StubPeriodProducerOptions) *StubPeriodProducer {
s := new(StubPeriodProducer)
s.last = opt.Last
s.periods = opt.Periods
return s
}
type StubPeriodProducer struct {
last *PeriodBound // для отдельного запроса
periods []stubPeriod
idx int
}
func (s *StubPeriodProducer) Next() (period time.Time, first PeriodBound, last PeriodBound, found bool, err error) {
if s.idx < len(s.periods) {
p := s.periods[s.idx]
s.idx++
period = p.Period
first = p.First
last = p.Last
found = true
}
return
}
func (s *StubPeriodProducer) Last() (next PeriodBound, found bool, err error) {
if s.last != nil {
next = *s.last
found = true
}
return
}

81
mqe/raw.go Normal file
View File

@@ -0,0 +1,81 @@
package mqe
import (
"database/sql"
"gordenko.dev/dima/qb"
)
type RawMeasure struct {
Time int64 `json:"t"`
Value float64 `json:"v"`
}
type RawMeasuresFilter struct {
MetricID int64 `json:"metricID"`
MetricType qb.MetricType `json:"metricType"`
Since int64 `json:"since"` // уже учтен firstHourOfDay
Until int64 `json:"until"` // уже учтен firstHourOfDay
}
// ListRawMeasures - cписок показаний мгновенных метрик (Температура, Давление, Расход)
// за за интервал без группировки
func (s *MeasureQueryEngine) ListRawMeasures(req RawMeasuresFilter) (_ []RawMeasure, err error) {
tx, err := s.db.Driver().Begin()
if err != nil {
return
}
defer tx.Rollback()
rows, err := tx.Query(`
SELECT tm, value
FROM f64
WHERE metricID=? AND tm BETWEEN ? AND ?
ORDER BY tm ASC`,
req.MetricID, req.Since, req.Until)
if err != nil {
if err == sql.ErrNoRows {
err = nil
}
return
}
defer rows.Close()
var result []RawMeasure
for rows.Next() {
var (
tm int64
value float64
)
err = rows.Scan(&tm, &value)
if err != nil {
return
}
result = append(result, RawMeasure{
Time: tm,
Value: value,
})
}
if err = rows.Err(); err != nil {
return
}
if len(result) == 0 {
return
}
if req.MetricType == qb.Cumulative {
var corrections []_f64Correction
corrections, err = listF64CorrectionsTx(tx, req.MetricID)
if err != nil {
return
}
if len(corrections) > 0 {
applyCorrectionsToRawMeasures(corrections, result)
}
}
return result, nil
}

179
mqe/readings.go Normal file
View File

@@ -0,0 +1,179 @@
package mqe
import (
"database/sql"
"fmt"
"sync"
"time"
"gordenko.dev/dima/pretty"
"gordenko.dev/dima/qb/timeutil"
)
////////////////////////////
type MetricMeasure struct {
Time int64 `json:"t"`
Value float64 `json:"v"`
}
type MetricError struct {
Time int64 `json:"time"`
Code int64 `json:"code"`
}
type ReadingsFilter struct {
Since string `json:"since"`
Until string `json:"until"`
MetricIDs []int64
}
type MetricReadings struct {
MetricID int64
Measures []MetricMeasure
Errors []MetricError
}
// SelectReadingsInParallel - метод достает из БД данные всех метрик, которые связаны
// с объектом. Учитываются настройки объекта (LastDayOfMonth, FirstHourOfDay), а также
// фильтры выбранные пользователем на странице объекта (Since, Until, GroupBy).
// ВАЖНО!
// Запросы по каждой метрике отправляются в БД в отдельной горутине, что позволяет
// в разы ускорить получение данных по объекту.
func (s *MeasureQueryEngine) SelectReadingsInParallel(req ReadingsFilter) (result []MetricReadings, err error) {
pretty.PPrintln("SelectReadingsInParallel", req)
if len(req.MetricIDs) == 0 {
return
}
if req.Since == "" {
err = fix.Field(EmptyValue, "since")
return
}
if req.Until == "" {
err = fix.Field(EmptyValue, "until")
return
}
// ВАЖНО!
// Если в строке нет таймзоны - Parse функция возвращает время в UTC.
// Поэтому нужно использовать ParseInLocation!
since, err := time.ParseInLocation("2006-01-02", req.Since, s.location)
if err != nil {
err = fix.Field(WrongValue, "since")
return
}
until, err := time.ParseInLocation("2006-01-02", req.Until, s.location)
if err != nil {
err = fix.Field(WrongValue, "until")
return
}
// Корректируем, ВСЕГДА на последнюю секунду суток
until = timeutil.LastSecondInPeriod(until, string(ByDay))
fmt.Printf(`SelectReadingsInParallel: {
Since: %s
Until: %s
MetricIDs: %v
}
`, since, until, req.MetricIDs)
// FIX проверить как добавляются часы в DST часовых поясах
// Для req.FirstHourOfDay=4 since должен быть 4:00:00, until должен быть
// 3:59:59 следующего дня
sinceUnixtime := since.Unix()
untilUnixtime := until.Unix()
if sinceUnixtime >= untilUnixtime {
err = fix.Error(WrongDateRange)
return
}
// Важно вирутальная метрика зависит от реальных, которые к объекту могут быть
// привязаны или нет. Если нет - не возвращать.
var (
tasks []*MetricReadingsTask
wg = new(sync.WaitGroup)
)
for _, metricID := range req.MetricIDs {
task := &MetricReadingsTask{
In: MetricMeasuresFilter{
MetricID: metricID,
Since: sinceUnixtime,
Until: untilUnixtime,
},
WaitGroup: wg,
}
wg.Add(1)
s.metricReadingsCh <- task
tasks = append(tasks, task)
}
wg.Wait()
for _, task := range tasks {
if task.Err != nil {
err = fmt.Errorf("get data for the metric %d: %s", task.In.MetricID, task.Err)
return
}
result = append(result, task.Result)
}
return
}
// ListInstantMeasures - cписок показаний для юнитов без накопительного итога
// (например, Давление)
func (s *MeasureQueryEngine) listMetricReadings(req MetricMeasuresFilter) (result MetricReadings, err error) {
result.MetricID = req.MetricID
// buf, _ := json.MarshalIndent(req, "", " ")
// fmt.Printf("listInstantMeasures: %s\n\n", buf)
tx, err := s.db.Driver().Begin()
if err != nil {
return
}
defer tx.Rollback()
rows, err := tx.Query(`
SELECT tm, value
FROM f64
WHERE metricID=? AND tm BETWEEN ? AND ?
ORDER BY tm ASC`,
req.MetricID, req.Since, req.Until)
if err != nil {
if err == sql.ErrNoRows {
err = nil
}
return
}
defer rows.Close()
for rows.Next() {
var measure MetricMeasure
err = rows.Scan(&measure.Time, &measure.Value)
if err != nil {
return
}
// fix - для cumulative метрик учет поправок
result.Measures = append(result.Measures, measure)
}
if err = rows.Err(); err != nil {
return
}
//for _, m := range result {
// fmt.Printf("raw: %d\t%f\n", m.Time, m.Value)
//}
//fmt.Printf("listInstantMeasures (qty): %d\n", len(result))
return
}

570
mqe/split.go Normal file
View File

@@ -0,0 +1,570 @@
package mqe
import (
"fmt"
"time"
)
func SplitByPeriods(producer periodProducer, calc PeriodCalculator) (result []AggregatedMeasure, err error) {
var (
isOpened bool
openedPeriod time.Time
since int64
sinceValue float64
until int64
untilValue float64
//
period time.Time
first, last PeriodBound
found bool
)
for {
period, first, last, found, err = producer.Next()
if err != nil {
return
}
if !found {
break
}
if isOpened {
if calc.IsExtendedSincePeriod(period) {
err = fmt.Errorf("extended period while already opened")
return
} else if calc.IsExtendedUntilPeriod(period) {
result = append(result, AggregatedMeasure{
Period: openedPeriod.Unix(),
Since: since,
Until: first.Time,
Values: []float64{
first.Value,
first.Value - sinceValue,
},
})
// Последний период успешно закрыли данными из Extended Until, поэтому выходим
return
} else {
if period.Equal(openedPeriod) {
// +
// Данная ситуация будет когда предыдущий период был корректно
// закрыт за 3 минуты до конца. После этого сразу открылся
// новый период. А запись для нового периода получена только
// сейчас.
if calc.IsPeriodCorrectEnds(period, last.Time) {
// CLOSE
result = append(result, AggregatedMeasure{
Period: openedPeriod.Unix(),
Since: since,
Until: last.Time,
Values: []float64{
last.Value,
last.Value - sinceValue,
},
})
// OPEN PERIOD (extended since, last 3 min closed)
openedPeriod = calc.NextPeriod(period)
if calc.IsExtendedUntilPeriod(openedPeriod) {
return
}
since = last.Time
sinceValue = last.Value
until = 0
untilValue = 0
} else {
// Период закроем где-то в будущем, на всякий случай запомнили последнее
// показание внутри периода
until = last.Time
untilValue = last.Value
}
} else {
// +
// Периоды не совпадают - смело закрываем
// Возможные ситуации:
// - был открыт мартовский период, а текущий период - апрель
// -
result = append(result, AggregatedMeasure{
Period: openedPeriod.Unix(),
Since: since,
Until: first.Time,
Values: []float64{
first.Value,
first.Value - sinceValue,
},
})
if first.Time != last.Time {
// +
openedPeriod = period
since = first.Time
sinceValue = first.Value
until = 0
untilValue = 0
if calc.IsPeriodCorrectEnds(period, last.Time) {
// Закрываем только что открытый период
result = append(result, AggregatedMeasure{
Period: openedPeriod.Unix(),
Since: since,
Until: last.Time,
Values: []float64{
last.Value,
last.Value - sinceValue,
},
})
openedPeriod = calc.NextPeriod(period)
if calc.IsExtendedUntilPeriod(openedPeriod) {
return
}
since = last.Time
sinceValue = last.Value
until = 0
untilValue = 0
} else {
// Период закроем где-то в будущем, на всякий случай запомнили последнее
// показание внутри периода
until = last.Time
untilValue = last.Value
}
} else {
// +
// first == last
if calc.IsPeriodCorrectEnds(period, last.Time) {
// Допустим закрыли предыдущий период 30 апреля в 23:58, новый стартуем за май.
openedPeriod = calc.NextPeriod(period)
if calc.IsExtendedUntilPeriod(openedPeriod) {
return
}
} else {
// Допустим закрыли предыдущий период 15 апреля, новый стартуем за апрель.
openedPeriod = period
}
since = last.Time
sinceValue = last.Value
until = 0
untilValue = 0
}
}
}
} else {
if calc.IsExtendedSincePeriod(period) {
// Если последние 3 минуты
if calc.IsPeriodCorrectEnds(period, last.Time) {
// OPEN PERIOD (extended since, last 3 min closed)
isOpened = true
openedPeriod = calc.NextPeriod(period)
since = last.Time
sinceValue = last.Value
until = 0
untilValue = 0
}
} else if calc.IsExtendedUntilPeriod(period) {
// Ничего не нашли, пустой результат
return
} else {
// OPEN PERIOD (regular first)
isOpened = true
openedPeriod = period
since = first.Time
sinceValue = first.Value
until = last.Time
untilValue = last.Value
if calc.IsPeriodCorrectEnds(openedPeriod, last.Time) {
// Закрываем только что открытый период
result = append(result, AggregatedMeasure{
Period: openedPeriod.Unix(),
Since: since,
Until: last.Time,
Values: []float64{
last.Value,
last.Value - sinceValue,
},
})
openedPeriod = calc.NextPeriod(period)
if calc.IsExtendedUntilPeriod(openedPeriod) {
return
}
since = last.Time
sinceValue = last.Value
until = 0
untilValue = 0
}
}
}
}
// Доп запрос.
if openedPeriod.IsZero() {
// Открытого периода не будет если вообще не нашли записей
return
}
next, found, err := producer.Last()
if err != nil {
return
}
if found {
result = append(result, AggregatedMeasure{
Period: openedPeriod.Unix(),
Since: since,
Until: next.Time,
Values: []float64{
next.Value,
next.Value - sinceValue,
},
})
} else {
if until > 0 {
result = append(result, AggregatedMeasure{
Period: openedPeriod.Unix(),
Since: since,
Until: until,
Values: []float64{
untilValue,
untilValue - sinceValue,
},
})
}
}
return
}
// Period - это начало периода, например для дней - это 0ч 0м 0с
// lastTm предыдущего периода всегда будет меньше current period
// since - это всегда сдвинутая метка времени (+ FirstHourOfDay * 3600)
// При FirstHourOfDay = 3 и группировке по дням, будет такая ситуация.
// Period будет указывать на 1 марта 00:00:00, а since на 1 марта 02:00:00,
// а lastTm предыдущего периода на 1 марта 01:59:00.
// Поэтому нельзя сравнивать текущий period и lastTm предыдущего периода.
// Преобразовать lastTm к period непонятно как если нужно учитывать LastDayOfMonth.
// Пример: 28 марта 00ч стало 1 апреля 00ч, а 27 марта 23:59 так и осталось.
// Можно вычислить endPeriodTime для previousPeriodEndTime, но текущий период может
// быть не строго следующим после предыдущего (например, июнь после марта).
// Правильное решение - берем firstTm текущего периода и вычисляем его реальное
// календарное начало. Например, FirstHourOfDay = 3, LastDayOfMonth = 28,
// firstTm = 1 мая. Календарное начало - 28 апреля 03:00:00. И уже с этим значением
// сравниваем previousPeriodEndTime.
// Но можно сравнивать since c lastTm и until c firstTm для определения
// extended периодов.
/*
func periodToCalendarTime(period time.Time, groupBy string, lastDayOfMonth, firstHourOfDay int) time.Time {
switch groupBy {
case "d":
if firstHourOfDay == 0 {
return period
}
// Было 0ч, стало - 2ч
return period.Add(time.Duration(firstHourOfDay) * time.Hour)
case "m":
if lastDayOfMonth == 0 {
if firstHourOfDay == 0 {
return period
}
// Было 0ч, стало - 2ч
return period.Add(time.Duration(firstHourOfDay) * time.Hour)
} else {
// текущий период начинается в прошлом месяце
tm := period.AddDate(0, -1, 0)
return time.Date(
tm.Year(),
tm.Month(),
lastDayOfMonth+1,
firstHourOfDay,
0,
0,
0,
tm.Location(),
)
}
default:
return period
}
}
*/
//var threeMinutes int64 = 3 * 60
/*
type readOptions struct {
MetricID int64 // только для readAggregatedMeasuresCanEndsInFuture
LastDayOfMonth int
FirstHourOfDay int
GroupBy string
Since int64
Until int64
ParsePeriodLayout string
}
*/
// Читает из sql.Rows показания и строит периоды
/*
func (s *MeasureQueryEngine) readAggregatedMeasuresCanEndsInFuture(tx *sql.Tx, rows *sql.Rows, opt readOptions) (result []CalculatedAggregatedMeasure, err error) {
var (
openPeriod time.Time
since int64
sinceValue float64
until int64
untilValue float64
//
frameStr string
firstTm int64
firstValue float64
lastTm int64
lastValue float64
period time.Time
)
for rows.Next() {
err = rows.Scan(&frameStr, &firstTm, &firstValue, &lastTm, &lastValue)
if err != nil {
return
}
period, err = time.ParseInLocation(opt.ParsePeriodLayout, frameStr, s.location)
if err != nil {
err = fmt.Errorf("time.ParseInLocation: %s; layout=%q; str=%q",
err, opt.ParsePeriodLayout, frameStr)
return
}
//fmt.Printf("%s s: %s, %v; u: %s, %v\n", frameStr, time.Unix(firstTm, 0), firstValue, time.Unix(lastTm, 0), lastValue)
// Читаем записи из БД. Одна запись - один период. Возможны 3 вида периодов:
// 1. extended since - период длиной до 3 минут перед since. Оптимизация, чтобы
// не отправлять дополнительный запрос.
// 2. обычный период.
// 3. extended until - период длиной до 1 часа после until. Оптимизация, чтобы
// не отправлять дополнительный запрос (в большинстве случаев).
//
// ВАЖНО!
// Любой период (1, 2 или 3) может встретится первым. Определить что это
// первая запись модно простой проверкой openPeriod.IsZero()
if lastTm < opt.Since {
// 1. extended since period
if isPeriodCorrectEnds(period, opt.GroupBy, opt.LastDayOfMonth, opt.FirstHourOfDay, lastTm) {
openPeriod = nextPeriod(period, opt.GroupBy)
since = lastTm
sinceValue = lastValue
}
continue
} else if firstTm > opt.Until {
// 3. extended until period
if openPeriod.IsZero() {
// Нет показаний кроме extended until, поэтому выходим
return
}
// закрываем открытый период
result = append(result, CalculatedAggregatedMeasure{
Period: openPeriod.Unix(),
Since: since,
Until: firstTm,
Value: firstValue,
Total: firstValue - sinceValue,
})
return
}
// 2. обычный период
if openPeriod.IsZero() {
// это первая запись (extended since period не найден)
openPeriod = period
since = firstTm
sinceValue = firstValue
} else {
if !period.Equal(openPeriod) {
//fmt.Printf("\nperiod %s != openPeriod %s\n", period, openPeriod)
// Пример: открытый период - март. В period - апрель.
// закрываем открытый период
result = append(result, CalculatedAggregatedMeasure{
Period: openPeriod.Unix(),
Since: since,
Until: firstTm,
Value: firstValue,
Total: firstValue - sinceValue,
})
// И сразу открываем новый период
openPeriod = period
since = firstTm
sinceValue = firstValue
}
// Периоды совпадают. Когда такая ситуация возможна?
// Например, на предыдущей итерации был корректно закрыт (57-59 минуты)
// прошлый период и сразу открыт новый (текущий).
}
if isPeriodCorrectEnds(openPeriod, opt.GroupBy, opt.LastDayOfMonth, opt.FirstHourOfDay, lastTm) {
// fmt.Printf("\nCORRECT END period %s\n", openPeriod)
// закрываем открытый период
result = append(result, CalculatedAggregatedMeasure{
Period: openPeriod.Unix(),
Since: since,
Until: lastTm,
Value: lastValue,
Total: lastValue - sinceValue,
})
// Открываем новый период
openPeriod = nextPeriod(openPeriod, opt.GroupBy)
since = lastTm
sinceValue = lastValue
} else {
// На последних минутах периода (57-59) не было показания, поэтому
// запоминаем конец периода какой есть. Если конец периода в будущем
// не будет найден - закроем период по текущему последнему показанию.
until = lastTm
untilValue = lastValue
}
// Валидируем новый период.
// Зачем?
// Пример: юзер задал opt.Until - 31 марта 23:59:59. Только что корректно
// закрылся март показанием от 31 марта 23:57:00. И сразу открылся новый
// период за апрель.
// Понять что закрыли последний период из запрошенных юзером просто -
// вычисляем календарное начало нового периода и сравниваем с opt.Until.
mustLessThanUntil := periodToCalendarTime(openPeriod, opt.GroupBy,
opt.LastDayOfMonth, opt.FirstHourOfDay)
if mustLessThanUntil.Unix() > opt.Until {
// Новый период вышел за opt.Until, а это означает что мы корректно
// закрыли последний период из запрошенных юзером. Bыходим.
return
}
}
// Цикл завершен, а из метода мы не вышли, а это означает одно - есть незакрытый
// период. Extended until не найден. Значит нужно отправить дополнительный запрос
// к БД.
var (
isNextValueFound bool
nextTm int64
nextValue float64
)
// Находим следующее показание метрики.
err = tx.QueryRow(`
SELECT tm, value, true
FROM f64
WHERE metricID=? AND tm > ?
ORDER BY tm ASC
LIMIT 1`,
opt.MetricID, opt.Until).Scan(&nextTm, &nextValue, &isNextValueFound)
if err != nil {
if err != sql.ErrNoRows {
return
}
// Нет записей
err = nil
}
if isNextValueFound {
// Найдено какое-то показание в будущем.
result = append(result, CalculatedAggregatedMeasure{
Period: openPeriod.Unix(),
Since: since,
Until: nextTm,
Value: nextValue,
Total: nextValue - sinceValue,
})
} else {
// Закрываем период самым последним показанием внутри периода
// (НЕ 57-59 минуты).
result = append(result, CalculatedAggregatedMeasure{
Period: openPeriod.Unix(),
Since: since,
Until: until,
Value: untilValue,
Total: untilValue - sinceValue,
})
}
return
}
*/
/*
// Читает из sql.Rows показания и строит периоды
func (s *MeasureQueryEngine) readAggregatedMeasuresEndsInsidePeriod(tx *sql.Tx, rows *sql.Rows, opt readOptions) (result []CalculatedAggregatedMeasure, err error) {
var (
previousPeriodEndTime int64
previousPeriodEndValue float64
//
frameStr string
firstTm int64
firstValue float64
lastTm int64
lastValue float64
period time.Time
)
for rows.Next() {
err = rows.Scan(&frameStr, &firstTm, &firstValue, &lastTm, &lastValue)
if err != nil {
return
}
period, err = time.ParseInLocation(opt.ParsePeriodLayout, frameStr, s.location)
if err != nil {
err = fmt.Errorf("time.ParseInLocation: %s; layout=%q; str=%q",
err, opt.ParsePeriodLayout, frameStr)
return
}
//fmt.Printf("%s s: %s, %v; u: %s, %v\n", frameStr, time.Unix(firstTm, 0), firstValue, time.Unix(lastTm, 0), lastValue)
if lastTm < opt.Since {
// extended since period
previousPeriodEndTime = lastTm
previousPeriodEndValue = lastValue
continue
} else if firstTm > opt.Until {
// extended until period
return
}
// целевой период. В будущем конец периода не ищем, поэтому закрываем сразу.
// Вычисляем реальное календарное начало периода. Например, period - 1 мая 00:00:00,
// FirstHourOfDay = 3, LastDayOfMonth = 28. Календарное начало - 29 апреля 03:00:00.
periodCalendarTime := periodToCalendarTime(period, opt.GroupBy, opt.LastDayOfMonth, opt.FirstHourOfDay)
minAge := periodCalendarTime.Add(-3 * time.Minute).Unix()
if previousPeriodEndTime > 0 && previousPeriodEndTime >= minAge {
result = append(result, CalculatedAggregatedMeasure{
Period: period.Unix(),
Since: previousPeriodEndTime,
Until: lastTm,
Value: lastValue,
Total: lastValue - previousPeriodEndValue,
})
} else {
result = append(result, CalculatedAggregatedMeasure{
Period: period.Unix(),
Since: firstTm,
Until: lastTm,
Value: lastValue,
Total: lastValue - firstValue,
})
}
previousPeriodEndTime = lastTm
previousPeriodEndValue = lastValue
}
return
}
*/

141
mqe/total.go Normal file
View File

@@ -0,0 +1,141 @@
package mqe
import (
"fmt"
"strings"
"sync"
"time"
"gordenko.dev/dima/qb/timeutil"
)
// TOTALS
type TotalMetricSpec struct {
MetricID int64
LastDayOfMonth int
FirstHourOfDay int
}
// LastDayOfMonth и FirstHourOfDay нет, ибо у каждой метрики свои настройки
type RangeTotalsFilter struct {
Since string
Until string
// Если учитывать не нужно - программа уровнем выше может передать нули.
// FIX у каждой метрики свои настройки
// искать ли конец периода в следующих периодах, если в последние 3 минуты периода
// не было показаний
CumulativeMetrics []TotalMetricSpec // metricID
}
// SelectRangeTotalsInParallel - метод рассчитывает накопленный объем от Since до Until
// по каждой метрике из массива CumulativeMetricIDs. Учитываются настройки объекта
// (LastDayOfMonth, FirstHourOfDay).
// ВАЖНО!
// Запросы по каждой метрике отправляются в БД в отдельной горутине, что позволяет
// в разы ускорить получение данных по объекту.
func (s *MeasureQueryEngine) GetRangeTotalsInParallel(req RangeTotalsFilter) (resultMap map[int64]RangeTotal, err error) {
if req.Since == "" {
err = fix.Field(EmptyValue, "since")
return
}
if req.Until == "" {
err = fix.Field(EmptyValue, "until")
return
}
// ВАЖНО!
// Если в строке нет таймзоны - Parse функция возвращает время в UTC.
// Поэтому нужно использовать ParseInLocation!
since, err := time.ParseInLocation("2006-01-02", req.Since, s.location)
if err != nil {
err = fix.Field(WrongValue, "since")
return
}
until, err := time.ParseInLocation("2006-01-02", req.Until, s.location)
if err != nil {
err = fix.Field(WrongValue, "until")
return
}
// Корректируем, чтобы указывало на последнюю секунду суток
until = timeutil.LastSecondInPeriod(until, string(ByDay))
var metrics []string
for _, metric := range req.CumulativeMetrics {
metrics = append(metrics, fmt.Sprintf(` {
MetricID: %d
LastDayOfMonth: %d
FirstHourOfDay: %d
}`, metric.MetricID, metric.LastDayOfMonth, metric.FirstHourOfDay))
}
fmt.Printf(`GetRangeTotalsInParallel: {
Since: %s
Until: %s
CumulativeMetrics [
%s
]
}
`, since, until, strings.Join(metrics, "\n"))
if since.Unix() >= until.Unix() {
err = fix.Error(WrongDateRange)
return
}
var (
tasks []*CumulativeTotalTask
wg = new(sync.WaitGroup)
)
resultMap = make(map[int64]RangeTotal)
// CUMULATIVE METRIC TOTALS
if len(req.CumulativeMetrics) > 0 {
for _, metric := range req.CumulativeMetrics {
// У каждой метрики индивидуальные настройки,
// ибо метрики принадлежат разным объектам!!!
metricSince := since
metricUntil := until
if metric.FirstHourOfDay > 0 {
metricSince = since.Add(time.Duration(metric.FirstHourOfDay) * time.Hour)
metricUntil = until.Add(time.Duration(metric.FirstHourOfDay) * time.Hour)
}
task := &CumulativeTotalTask{
In: getCumulativeTotalFilter{
MetricID: metric.MetricID,
Since: metricSince.Unix(),
Until: metricUntil.Unix(),
LastDayOfMonth: metric.LastDayOfMonth,
FirstHourOfDay: metric.FirstHourOfDay,
},
WaitGroup: wg,
}
wg.Add(1)
s.cumulativeTotalCh <- task
tasks = append(tasks, task)
}
}
wg.Wait()
for _, task := range tasks {
if task.Err != nil {
err = fmt.Errorf("get data for the metric %d: %s", task.In.MetricID, task.Err)
return
}
if task.Result != nil {
resultMap[task.In.MetricID] = *task.Result
} else {
resultMap[task.In.MetricID] = RangeTotal{}
}
}
return
}

138
mqe/unaggregated.go Normal file
View File

@@ -0,0 +1,138 @@
package mqe
import (
"fmt"
"sync"
"time"
)
// Measure - рассчитанное показание реальной метрики. Рассчитанное означает
// что учтен Factor. То есть значение уже можно показывать юзеру
// Для instant - одно значение value, для cumulative - 2 значения (value, total)
type Measure struct {
Time int64 `json:"t"`
Values []float64 `json:"v"`
}
type UnaggregatedFilter struct {
Since time.Time
Until time.Time
LastDayOfMonth int // например, конец месяца 25 число
FirstHourOfDay int // например день начинается в 6:00
InstantMetrics []int64
CumulativeMetrics []int64
}
// type MetricsDataResultMaps struct {
// AggregatedCumulative map[int64][]CalculatedAggregatedMeasure
// AggregatedByFuncInstant map[int64][]AggregatedByFuncInstantMeasure
// Cumulative map[int64][]CalculatedMeasure
// Instant map[int64][]CalculatedMeasure
// }
// SelectObjectDataInParallel - метод достает из БД данные всех метрик, которые связаны
// с объектом. Учитываются настройки объекта (LastDayOfMonth, FirstHourOfDay), а также
// фильтры выбранные пользователем на странице объекта (Since, Until, GroupBy).
// ВАЖНО!
// Запросы по каждой метрике отправляются в БД в отдельной горутине, что позволяет
// в разы ускорить получение данных по объекту.
func (s *MeasureQueryEngine) SelectUnaggregatedData(req UnaggregatedFilter) (resultMap map[int64][]Measure, err error) {
since := req.Since
until := req.Until
if req.FirstHourOfDay < 0 || req.FirstHourOfDay > 23 {
err = fix.Field(InvalidFirstHourOfDay, "firstHourOfDay")
return
}
if req.LastDayOfMonth < 0 || req.LastDayOfMonth > 28 {
err = fix.Field(InvalidLastDayOfMonth, "lastDayOfMonth")
return
}
// fmt.Printf(`SelectUnaggregatedData: {
// Since: %s
// Until: %s
// FirstHourOfDay: %d
// LastDayOfMonth: %d
// }
// `, since, until, req.FirstHourOfDay, req.LastDayOfMonth)
// FIX проверить как добавляются часы в DST часовых поясах
// Для req.FirstHourOfDay=4 since должен быть 4:00:00, until должен быть
// 3:59:59 следующего дня
if req.FirstHourOfDay > 0 {
since = since.Add(time.Duration(req.FirstHourOfDay) * time.Hour)
until = until.Add(time.Duration(req.FirstHourOfDay) * time.Hour)
}
sinceUnixtime := since.Unix()
untilUnixtime := until.Unix()
if sinceUnixtime >= untilUnixtime {
err = fix.Error(WrongDateRange)
return
}
var (
cumulativeTasks []*CumulativeTask
instantTasks []*InstantTask
wg = new(sync.WaitGroup)
)
// Важно вирутальная метрика зависит от реальных, которые к объекту могут быть
// привязаны или нет. Если нет - не возвращать.
resultMap = make(map[int64][]Measure)
if len(req.CumulativeMetrics) > 0 {
for _, metricID := range req.CumulativeMetrics {
task := &CumulativeTask{
In: MetricMeasuresFilter{
MetricID: metricID,
Since: sinceUnixtime,
Until: untilUnixtime,
},
WaitGroup: wg,
}
wg.Add(1)
s.cumulativeCh <- task
cumulativeTasks = append(cumulativeTasks, task)
}
}
if len(req.InstantMetrics) > 0 {
for _, metricID := range req.InstantMetrics {
task := &InstantTask{
In: MetricMeasuresFilter{
MetricID: metricID,
Since: sinceUnixtime,
Until: untilUnixtime,
},
WaitGroup: wg,
}
wg.Add(1)
s.instantCh <- task
instantTasks = append(instantTasks, task)
}
}
wg.Wait()
for _, task := range cumulativeTasks {
if task.Err != nil {
err = fmt.Errorf("get data for the metric %d: %s", task.In.MetricID, task.Err)
return
}
resultMap[task.In.MetricID] = task.Result
}
for _, task := range instantTasks {
if task.Err != nil {
err = fmt.Errorf("get data for the metric %d: %s", task.In.MetricID, task.Err)
return
}
resultMap[task.In.MetricID] = task.Result
}
return
}