diff --git a/README.md b/README.md index 7c702b39..40cc3397 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ ## Временные комменты -Когда добавляешь новый код, или поле в структуру, можно написать над этим полем комментарий. Потом его можно будет стереть, но мне кажется прикольно, что в коммите навсегда останется намерение с котором было сделано изменение. +Когда добавляешь новый код, или поле в структуру, можно написать над этим полем комментарий. Потом его можно будет стереть, но мне кажется прикольно, что в коммите навсегда останется намерение с котором было сделано изменение. ## А ещё diff --git a/cmd/server/server.go b/cmd/server/server.go index d755be5a..f2be7240 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -127,7 +127,7 @@ func ConfigureStorage(cfg config) (api.Operator, context.CancelFunc) { panic(e) } - dbs, err := storage.NewDatabase(db) // todo зачем тут контекст??? + dbs, err := storage.NewDatabase(db) if err != nil { log.Error().Msgf("Ошибка создания хранилища в базе данных - %v", err) panic(err) diff --git a/internal/report/send.go b/internal/report/send.go index 538f3e6d..30c6a20a 100644 --- a/internal/report/send.go +++ b/internal/report/send.go @@ -10,6 +10,8 @@ import ( "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" "github.com/thefrol/kysh-kysh-meow/internal/metrica" + "github.com/thefrol/kysh-kysh-meow/lib/retry" + "github.com/thefrol/kysh-kysh-meow/lib/retry/fails" ) var ( @@ -34,7 +36,23 @@ func Send(metricas []metrica.Metrica, url string) error { // у нас существует очень важный контракт, // что тело сюда передается в формате io.Reader, // тогда могут работать разные мидлвари - resp, err := defaultClient.R().SetBody(buf).Post(url) // todo в данный момент мы не используем тут easyjson + var resp *resty.Response + sendCall := func() error { + var err error + resp, err = defaultClient.R().SetBody(buf).Post(url) + return err + } + + // запустим отправку с тремя попытками дополнительными + err = retry.This(sendCall, + retry.Attempts(3), + retry.DelaySeconds(1, 3, 5, 7), + retry.If(fails.OnDial), // черт, так красиво + retry.OnRetry(func(n int, err error) { + log.Info().Msgf("%v попытка отправить данные, ошибка: %v", n, err) + }), + ) + // todo в данный момент мы не используем тут easyjson if err != nil { log.Error().Str("location", "internal/report").Msgf("Не могу отправить сообщение c пачкой метрик по приничине %+v", err) diff --git a/internal/server/api/batch_handlers.go b/internal/server/api/batch_handlers.go index c9b11012..ca863b1d 100644 --- a/internal/server/api/batch_handlers.go +++ b/internal/server/api/batch_handlers.go @@ -6,6 +6,7 @@ package api import ( "context" "encoding/json" + "errors" "net/http" "github.com/thefrol/kysh-kysh-meow/internal/metrica" @@ -48,7 +49,7 @@ func HandleJSONBatch(handler func(context.Context, ...metrica.Metrica) (out []me // Запускаем обработчик handler() out, err := handler(r.Context(), in...) if err != nil { - if err == ErrorNotFoundMetric { + if errors.Is(err, ErrorNotFoundMetric) { HTTPErrorWithLogging(w, http.StatusNotFound, "В хранилище не найдена одна из метрик %v", in) return } diff --git a/internal/server/api/error.go b/internal/server/api/error.go index 94fbb75f..e16a6f9c 100644 --- a/internal/server/api/error.go +++ b/internal/server/api/error.go @@ -1,10 +1,13 @@ package api import ( + "context" "fmt" "net/http" "github.com/rs/zerolog/log" + "github.com/thefrol/kysh-kysh-meow/lib/retry" + "github.com/thefrol/kysh-kysh-meow/lib/retry/fails" ) // httpErrorWithLogging отправляет сообщение об ошибке, параллельно дублируя в журнал. Работает быстрее, чем просто две функции отдельно. @@ -21,3 +24,73 @@ func HTTPErrorWithLogging(w http.ResponseWriter, statusCode int, format string, // // Возможно это пока единственный повод держать кастомный логгер, чтобы в нем была функция типа withHttpError(w) } + +// Retry3Times повторяет операцию op ровно три раза +// с промежутками 1,3,5 секунд. +// +// retriableHandler:=Retry3Times(handler) +// out, err:=retryableHandler(ctx, in) +func Retry3Times(op Operation) Operation { + return func(ctx context.Context, d ...datastruct) (out []datastruct, err error) { + err = + retry.This( + func() error { + out, err = op(ctx, d...) + return err + }, + retry.If(fails.OnDial), + retry.Attempts(3), + retry.DelaySeconds(1, 3, 5, 7), + retry.OnRetry( + func(i int, err error) { + log.Info().Msgf("Выполняется повторная попытка %v после ошибки %v", i, err) + }), + ) + if err != nil { + return nil, err + } + return + } +} + +// RetryWithTimeouts повторяет операцию c паузузами между операциями, +// указанными при закуске. Количество повторений зависит от колчества таймаутов +// Если таймауты не указаны, то используется три повтора с интевалами 1,3,5 секунд +// +// retriableHandler:=Retry3Times(handler) +// out, err:=retryableHandler(ctx, in) +func RetryWithTimeouts(op Operation, timeOutSeconds ...uint) Operation { + return func(ctx context.Context, d ...datastruct) (out []datastruct, err error) { + var ( + timeouts retry.Option + attempts retry.Option + ) + + if len(timeOutSeconds) == 0 { + timeouts = retry.DelaySeconds(1, 3, 5) + attempts = retry.Attempts(3) + } else { + timeouts = retry.DelaySeconds(timeOutSeconds...) + attempts = retry.Attempts(uint(len(timeOutSeconds))) + } + + err = + retry.This( + func() error { + out, err = op(ctx, d...) + return err + }, + retry.If(fails.OnDial), + attempts, + timeouts, + retry.OnRetry( + func(i int, err error) { + log.Info().Msgf("Выполняется повторная попытка %v после ошибки %v", i, err) + }), + ) + if err != nil { + return nil, err + } + return + } +} diff --git a/internal/server/api/json_handlers.go b/internal/server/api/json_handlers.go index ee67df32..fd38c2f6 100644 --- a/internal/server/api/json_handlers.go +++ b/internal/server/api/json_handlers.go @@ -5,6 +5,7 @@ package api import ( "context" + "errors" "net/http" "github.com/mailru/easyjson" @@ -59,7 +60,7 @@ func HandleJSONRequest(handler func(context.Context, ...metrica.Metrica) (out [] // Запускаем обработчик handler() arr, err := handler(r.Context(), in) if err != nil { - if err == ErrorNotFoundMetric { + if errors.Is(err, ErrorNotFoundMetric) { HTTPErrorWithLogging(w, http.StatusNotFound, "В хранилище не найдена метрика %v", in.ID) return } diff --git a/internal/server/api/url_handlers.go b/internal/server/api/url_handlers.go index 0900cfed..4c45cbc1 100644 --- a/internal/server/api/url_handlers.go +++ b/internal/server/api/url_handlers.go @@ -3,6 +3,7 @@ package api import ( + "errors" "net/http" "strconv" @@ -31,12 +32,12 @@ func HandleURLRequest(op Operation) http.HandlerFunc { arr, err := op(r.Context(), in) if err != nil { // todo вот этот код встречается в соседних обертках - if err == ErrorDeltaEmpty || err == ErrorValueEmpty { + if errors.Is(err, ErrorDeltaEmpty) || errors.Is(err, ErrorValueEmpty) { HTTPErrorWithLogging(w, http.StatusBadRequest, "Ошибка входных данных: %v", err) - } else if err == ErrorNotFoundMetric { + } else if errors.Is(err, ErrorNotFoundMetric) { HTTPErrorWithLogging(w, http.StatusNotFound, "Не найдена метрика %v с именем %v", in.MType, in.ID) return - } else if err == ErrorUnknownMetricType { + } else if errors.Is(err, ErrorUnknownMetricType) { HTTPErrorWithLogging(w, http.StatusBadRequest, "Неизвестный тип счетчика: %v", in.MType) return } diff --git a/internal/server/app/README.md b/internal/server/app/README.md index 9c3c5537..1b3463dd 100644 --- a/internal/server/app/README.md +++ b/internal/server/app/README.md @@ -23,6 +23,34 @@ router.Post("/ping", App.Ping) router.Get("/list", App.List) ``` -`Ping()` переедет из `store` в `App`, и хоть это может показаться глуповатым на первый взгляд, стоит отментить задание. А в нашей архитектуре мы стараемся держаться поближе к предметами реального мира. `/ping` должно работать именно с БД. А стораж не знает в общем случае, БД он или нет. +`Ping()` переедет из `store` в `App`, и хоть это может показаться глуповатым на первый взгляд, стоит отментить задание. А в нашей архитектуре мы стараемся держаться поближе к предметами реального мира. `/ping` должно работать именно с БД. А стораж не знает в общем случае, БД он или нет. -`Operator` скорее всего передет из хендлеров. И это позволит избавиться от зависимости к хранилищу у хелдлеров. Связывать `handlers` и `storage` мы будем через `app`. Таким образом и хендлеры и хранилище получяит зависимостиь от более высокоуровнего СЕРВИСА, но при этом освободятся от зависимостей между друг другом. При этом App тоже не будет от них зависеть. Отличная тема однако! \ No newline at end of file +`Operator` скорее всего передет из хендлеров. И это позволит избавиться от зависимости к хранилищу у хелдлеров. Связывать `handlers` и `storage` мы будем через `app`. Таким образом и хендлеры и хранилище получяит зависимостиь от более высокоуровнего СЕРВИСА, но при этом освободятся от зависимостей между друг другом. При этом App тоже не будет от них зависеть. Отличная тема однако. + +### Ошибки + +Тоже перетекают в апп + +### Operator + +Отправится в `app`, а вот `Operator` пока останется в `api` + +## А что дельше? + +Мне кажется, что мне не нужны фабричные методы, вместо этого мне нужно компоновать методы во что-то большее, типа + +```go +router.Post("/list",Handler(app.ListMetrics)) + +gRPC.AddService("/list",grpcHandler(app.ListMetrics)) + +func (api App) List(attrs ...atts) []rets{ + // validate + validate(in) + + //handle + out:=hanlde(app.store) +} +``` + +Типа эти функции компонуются из других функций. И потом эти бизнес правила могут использоваться как в `HTTP`, так и в `gRPC` diff --git a/internal/server/router/router.go b/internal/server/router/router.go index fed97c6b..2f0cd979 100644 --- a/internal/server/router/router.go +++ b/internal/server/router/router.go @@ -28,20 +28,20 @@ func MeowRouter(store api.Operator) (router chi.Router) { router.Group(func(r chi.Router) { // в какой-то момент, когда починят тесты, тут можно будет снять комменты //r.With(chimiddleware.AllowContentType("text/plain")) todo - r.Get("/value/{type}/{name}", api.HandleURLRequest(store.Get)) - r.Post("/update/{type}/{name}/{value}", api.HandleURLRequest(store.Update)) + r.Get("/value/{type}/{name}", api.HandleURLRequest(api.Retry3Times(store.Get))) + r.Post("/update/{type}/{name}/{value}", api.HandleURLRequest(api.Retry3Times(store.Update))) }) // Создаем маршруты для обработки JSON запросов router.Group(func(r chi.Router) { r.With(chimiddleware.AllowContentType("application/json")) - r.Post("/value", api.HandleJSONRequest(store.Get)) - r.Post("/value/", api.HandleJSONRequest(store.Get)) - r.Post("/update", api.HandleJSONRequest(store.Update)) - r.Post("/update/", api.HandleJSONRequest(store.Update)) - r.Post("/updates", api.HandleJSONBatch(store.Update)) - r.Post("/updates/", api.HandleJSONBatch(store.Update)) + r.Post("/value", api.HandleJSONRequest(api.Retry3Times(store.Get))) + r.Post("/value/", api.HandleJSONRequest(api.Retry3Times(store.Get))) + r.Post("/update", api.HandleJSONRequest(api.Retry3Times(store.Update))) + r.Post("/update/", api.HandleJSONRequest(api.Retry3Times(store.Update))) + r.Post("/updates", api.HandleJSONBatch(api.Retry3Times(store.Update))) + r.Post("/updates/", api.HandleJSONBatch(api.Retry3Times(store.Update))) // TODO // diff --git a/internal/storage/database.go b/internal/storage/database.go index 37bc30ca..9a33b16a 100644 --- a/internal/storage/database.go +++ b/internal/storage/database.go @@ -8,6 +8,8 @@ import ( "github.com/rs/zerolog/log" "github.com/thefrol/kysh-kysh-meow/internal/metrica" "github.com/thefrol/kysh-kysh-meow/internal/server/api" + "github.com/thefrol/kysh-kysh-meow/lib/retry" + "github.com/thefrol/kysh-kysh-meow/lib/retry/fails" ) var ( @@ -23,8 +25,7 @@ const ( queryList = "SELECT 'counter',id FROM counters UNION SELECT 'gauge',id from gauges;" - queryUpdateCounter = "UPDATE counters SET delta=delta+$2 WHERE id=$1" - queryInsertCounter = "INSERT INTO counters VALUES ($1,$2);" + queryUpsertCounter = "INSERT INTO counters VALUES ($1,$2) ON CONFLICT (id) DO UPDATE SET delta=counters.delta+$2;" queryUpsertGauge = "INSERT INTO gauges VALUES ($1,$2) ON CONFLICT (id) DO UPDATE SET value=$2;" ) @@ -38,7 +39,20 @@ func NewDatabase(db *sql.DB) (*Database, error) { // инициализуем таблицы для гаужей и каунтеров // // todo использовать транзации с отменой - _, err := db.Exec(initQuery) + err := + retry.This( + func() error { + _, err := db.Exec(initQuery) + return err + }, + retry.If(fails.OnDial), + retry.Attempts(3), + retry.DelaySeconds(1, 3, 5, 7), + retry.OnRetry( + func(i int, err error) { + log.Info().Msgf("Попытка инициализации базы %v: %v", i, err) + })) + if err != nil { return nil, ErrorInitDatabase } @@ -69,7 +83,7 @@ func (d *Database) Get(ctx context.Context, req ...metrica.Metrica) (resp []metr err := rw.Scan(&result.ID, result.Delta) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, api.ErrorNotFoundMetric + return nil, api.ErrorNotFoundMetric // todo, вот тут можно упаковку ошибки сделать впринципе } return nil, err } @@ -81,7 +95,7 @@ func (d *Database) Get(ctx context.Context, req ...metrica.Metrica) (resp []metr err := rw.Scan(&result.ID, result.Value) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, api.ErrorNotFoundMetric + return nil, api.ErrorNotFoundMetric // todo упаковать ошибку??? } return nil, err } @@ -132,34 +146,14 @@ func (d *Database) Update(ctx context.Context, req ...metrica.Metrica) (resp []m for _, r := range req { switch r.MType { case "counter": - /* - 1. Пробуем делать UPDATE ... SET value=value+delta - 2. Если количество обновленных полей =0, делаем INSERT INTO - */ - if r.Delta == nil { return nil, api.ErrorDeltaEmpty } - // todo не помню, надо ли тут проверять на всякие пустые ссылки... - rs, err := tx.ExecContext(ctx, queryUpdateCounter, r.ID, r.Delta) - if err != nil { - return nil, err - } - count, err := rs.RowsAffected() + _, err := tx.ExecContext(ctx, queryUpsertCounter, r.ID, r.Delta) if err != nil { return nil, err } - if count > 1 { - log.Warn().Msgf("При транзакции обновлено несколько строк %+v", r) - } - if count == 0 { - // Значит счетчик не создан, значит создадим - _, err := tx.ExecContext(ctx, queryInsertCounter, r.ID, r.Delta) - if err != nil { - return nil, err - } - } case "gauge": if r.Value == nil { diff --git a/lib/retry/do.go b/lib/retry/do.go new file mode 100644 index 00000000..38a24ac6 --- /dev/null +++ b/lib/retry/do.go @@ -0,0 +1,83 @@ +package retry + +import ( + "errors" + "fmt" + "time" +) + +type Options struct { + delays []time.Duration + maxretries int + conditions []func(error) bool + callbacks []RetryCallback +} + +// This запускает несколько попыток запустить функцию, +// новые попытки будут совершаться, только ошибка +// обернута при помощи retry.Retriable(), или если выполняется +// одно из условий переданных при помощи If() или IfError() +func This(f func() error, opts ...Option) error { + + options := Options{} + + // укажем стандартные настройки + Attempts(1)(&options) + DelaySeconds(1)(&options) + + for _, opt := range opts { + err := opt(&options) + if err != nil { + return fmt.Errorf("ошибка опции при запуске восстанавливаемой функции: %w", err) + } + } + + var err error + for i := 0; i <= options.maxretries; i++ { + // Мы пускаем массив задержек по кругу, кроме первой попытки + if i > 0 { + currentI := int((i - 1) % len(options.delays)) + time.Sleep(options.delays[currentI]) + + // вызываем коллбеки перед вызовом функции, то есть после того + // как счетчик i поменял значение, а раньше нужно было бы проверить, + // что следущая итерация случится + for _, c := range options.callbacks { + c(i, err) + } + continue + } + + err = f() + if err == nil { + return nil + } + if canretry(err, options) { + // отсюда перенесен код, чтобы аглоритм легче читался + continue + } + + return err + } + return err +} + +func canretry(err error, opts Options) bool { + // todo + // + // Интересная тема, что в самой бы ошибке + // можно было бы указать сколько раз функцию можно + // перезапустить и с какими интервалами + var re *RetriableError + if errors.As(err, &re) { + // запускаем коллбеки перед повторным запуском + return true + } + + for _, cond := range opts.conditions { + if cond(err) { + return true + } + } + return false +} diff --git a/lib/retry/fails/errors.go b/lib/retry/fails/errors.go new file mode 100644 index 00000000..2506727e --- /dev/null +++ b/lib/retry/fails/errors.go @@ -0,0 +1,24 @@ +// Некоторые ошибки временны, и функции их получившие можно просто +// попытаться выполнить ещё раз, и в следующий раз все нормально +// завершиться +// +// В этом пакете собраны функции-проверялки таких ошибок, из +// самых растространенных случаях, таких как http и бд +package fails + +import ( + "errors" + "net" +) + +// OnDial возвращает true, если err связана с ошибкой подключения +// то есть ошибка net.OpError, где operr.Op=="dial" +// +// retry.This(func ()error{}, retry.If(fails.OnDial)) +func OnDial(err error) bool { + var oe *net.OpError + if errors.As(err, &oe) { + return oe.Op == "dial" // если ошибка в операции dial + } + return false +} diff --git a/lib/retry/optfuncs.go b/lib/retry/optfuncs.go new file mode 100644 index 00000000..880ed3cc --- /dev/null +++ b/lib/retry/optfuncs.go @@ -0,0 +1,68 @@ +package retry + +import ( + "errors" + "time" +) + +type Option func(opt *Options) error + +type RetryCallback func(int, error) + +// DelaySeconds позволяет указать задержки между попытками, +// если повторов будет больше, чем задержек, то задержки +// будут повторяться по кругу +func DelaySeconds(seconds ...uint) Option { + return func(opt *Options) error { + var delays []time.Duration + for _, interval := range seconds { + delays = append(delays, time.Second*time.Duration(interval)) + } + opt.delays = delays + return nil + } +} + +func OnRetry(funs ...RetryCallback) Option { + return func(opt *Options) error { + opt.callbacks = append(opt.callbacks, funs...) + return nil + } +} + +// Attempts позволяет установить количество повторных попыток, +// и это значит что указав Attempts(3), мы запуст функцию +// максимум четыре раза - один штатный запуск, и три попытки +// чтобы она сработала после. +func Attempts(count uint) Option { + return func(opt *Options) error { + opt.maxretries = int(count) + return nil + } +} + +// If позволяет указать функцию, которая проверит можно ли с +// ошибкой err делать ещё одну попытку. +// +// В одном запуске может быть несколько таких условий, функция сработает +// если выполнится одно из них +func If(f func(error) bool) Option { + return func(opt *Options) error { + opt.conditions = append(opt.conditions, f) + return nil + } +} + +// IfError позволяет повторить запуск, если повторяемая функция вернула +// определенную ошибку, например io.EOF +// +// В одном запуске может быть несколько таких условий, функция сработает +// если выполнится одно из них +func IfError(target error) Option { + return func(opt *Options) error { + opt.conditions = append(opt.conditions, func(err error) bool { + return errors.Is(err, target) + }) + return nil + } +} diff --git a/lib/retry/retriable.go b/lib/retry/retriable.go new file mode 100644 index 00000000..96134061 --- /dev/null +++ b/lib/retry/retriable.go @@ -0,0 +1,19 @@ +package retry + +type RetriableError struct { + Err error +} + +func Retriable(err error) error { + return &RetriableError{ + Err: err, + } +} + +func (err *RetriableError) Error() string { + return err.Err.Error() +} + +func (err *RetriableError) Unwrap() error { + return err.Err +} diff --git a/lib/retry/retry_test.go b/lib/retry/retry_test.go new file mode 100644 index 00000000..b839cdfc --- /dev/null +++ b/lib/retry/retry_test.go @@ -0,0 +1,62 @@ +package retry_test + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/thefrol/kysh-kysh-meow/lib/retry" +) + +func Test_RetriableErrorWorks(t *testing.T) { + t.Run("Обернутость в ретриабл работает", func(t *testing.T) { + callee := func() error { + return retry.Retriable(errors.New("test error")) + } + + start := time.Now() + retry.This(callee, + retry.Attempts(1), + retry.DelaySeconds(1, 1, 2)) + + dur := time.Since(start) + assert.True(t, dur > time.Millisecond*1000, "Должно хотя бы секунду длиться") + }) + + t.Run("Если ошибка не ретриабл, то не повторяем", func(t *testing.T) { + callee := func() error { + return errors.New("not retriable") + } + + start := time.Now() + retry.This(callee, + retry.Attempts(2), + retry.DelaySeconds(1, 1, 2)) + + dur := time.Since(start) + assert.True(t, dur < time.Millisecond*10, "Не должно быть задержек") + }) + +} + +func Test_Callbacks(t *testing.T) { + t.Run("четыре запуска, три ретрая, три коллбека", func(t *testing.T) { + + callee := func() error { + return retry.Retriable(errors.New("test error")) + } + + counter := 0 + increment := func(int, error) { + counter++ + } + + retry.This(callee, + retry.Attempts(3), + retry.DelaySeconds(1, 1, 1), + retry.OnRetry(increment)) + + assert.Equal(t, 3, counter, "Коллбеки должны были запуститься три раза") + }) +}