From 62c53a6b4d180e7fb7b0e4e6c850e12af3ec7fec Mon Sep 17 00:00:00 2001 From: Mike Stefanello Date: Sat, 22 Jun 2024 10:34:26 -0400 Subject: [PATCH] Default to SQLite rather than Postgres & Redis (#72) * Initial rough draft switch to sqlite. * Rewrote cache implemenation. * Provide typed tasks. * Task cleanup. * Use same db for tasks. * Provide task queue registration and service container injection. * Added optional delay to tasks. Pool buffers when encoding. * Added tests for the task client and runner. * Added handler examples for caching and tasks. * Cleanup and documentation. * Use make in workflow. * Updated documentation. * Updated documentation. --- .github/workflows/test.yml | 7 +- .gitignore | 1 + Makefile | 57 ------ README.md | 235 ++++++++-------------- cmd/web/main.go | 21 +- cmd/worker/main.go | 45 ----- config/config.go | 25 +-- config/config.yaml | 20 +- docker-compose.yml | 18 -- go.mod | 32 +-- go.sum | 243 ++--------------------- pkg/form/submission.go | 2 + pkg/handlers/cache.go | 92 +++++++++ pkg/handlers/contact.go | 1 - pkg/handlers/task.go | 88 ++++++++ pkg/middleware/cache.go | 3 +- pkg/services/cache.go | 320 +++++++++++++++++++++--------- pkg/services/cache_test.go | 57 +++--- pkg/services/container.go | 98 ++++----- pkg/services/tasks.go | 275 +++++++++++++------------ pkg/services/tasks_test.go | 88 +++++--- pkg/services/template_renderer.go | 3 +- pkg/tasks/example.go | 38 ++-- pkg/tasks/register.go | 10 + templates/layouts/main.gohtml | 2 + templates/pages/about.gohtml | 2 +- templates/pages/cache.gohtml | 36 ++++ templates/pages/task.gohtml | 43 ++++ templates/templates.go | 4 +- 29 files changed, 956 insertions(+), 910 deletions(-) delete mode 100644 cmd/worker/main.go delete mode 100644 docker-compose.yml create mode 100644 pkg/handlers/cache.go create mode 100644 pkg/handlers/task.go create mode 100644 pkg/tasks/register.go create mode 100644 templates/pages/cache.gohtml create mode 100644 templates/pages/task.gohtml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3180cdf..5550d26 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,10 +27,5 @@ jobs: restore-keys: | ${{ runner.os }}-go- - - name: Start containers - run: | - docker-compose up -d - sleep 3 - - name: Test - run: go test -p 1 ./... + run: make test diff --git a/.gitignore b/.gitignore index 485dee6..cd5d60f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .idea +dbs \ No newline at end of file diff --git a/Makefile b/Makefile index 02044ef..57fad5d 100644 --- a/Makefile +++ b/Makefile @@ -1,32 +1,3 @@ -# Determine if you have docker-compose or docker compose installed locally -# If this does not work on your system, just set the name of the executable you have installed -DCO_BIN := $(shell { command -v docker-compose || command -v docker compose; } 2>/dev/null) - -# Connect to the primary database -.PHONY: db -db: - docker exec -it pagoda_db psql postgresql://admin:admin@localhost:5432/app - -# Connect to the test database (you must run tests first before running this) -.PHONY: db-test -db-test: - docker exec -it pagoda_db psql postgresql://admin:admin@localhost:5432/app_test - -# Connect to the primary cache -.PHONY: cache -cache: - docker exec -it pagoda_cache redis-cli - -# Clear the primary cache -.PHONY: cache-clear -cache-clear: - docker exec -it pagoda_cache redis-cli flushall - - # Connect to the test cache -.PHONY: cache-test -cache-test: - docker exec -it pagoda_cache redis-cli -n 1 - # Install Ent code-generation module .PHONY: ent-install ent-install: @@ -42,28 +13,6 @@ ent-gen: ent-new: go run entgo.io/ent/cmd/ent new $(name) -# Start the Docker containers -.PHONY: up -up: - $(DCO_BIN) up -d - sleep 3 - -# Stop the Docker containers -.PHONY: stop -stop: - $(DCO_BIN) stop - -# Drop the Docker containers to wipe all data -.PHONY: down -down: - $(DCO_BIN) down - -# Rebuild Docker containers to wipe all data -.PHONY: reset -reset: - $(DCO_BIN) down - make up - # Run the application .PHONY: run run: @@ -75,12 +24,6 @@ run: test: go test -count=1 -p 1 ./... -# Run the worker -.PHONY: worker -worker: - clear - go run cmd/worker/main.go - # Check for direct dependency updates .PHONY: check-updates check-updates: diff --git a/README.md b/README.md index 897d59e..6395947 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ * [Dependencies](#dependencies) * [Start the application](#start-the-application) * [Running tests](#running-tests) - * [Clients](#clients) * [Service container](#service-container) * [Dependency injection](#dependency-injection) * [Test dependencies](#test-dependencies) @@ -82,9 +81,8 @@ * [Flush tags](#flush-tags) * [Tasks](#tasks) * [Queues](#queues) - * [Scheduled tasks](#scheduled-tasks) - * [Worker](#worker) - * [Monitoring](#monitoring) + * [Runner](#runner) +* [Cron](#cron) * [Static files](#static-files) * [Cache control headers](#cache-control-headers) * [Cache-buster](#cache-buster) @@ -123,8 +121,9 @@ Go server-side rendered HTML combined with the projects below enable you to crea #### Storage -- [PostgreSQL](https://www.postgresql.org/): The world's most advanced open source relational database. -- [Redis](https://redis.io/): In-memory data structure store, used as a database, cache, and message broker. +- [SQLite](https://sqlite.org/): A small, fast, self-contained, high-reliability, full-featured, SQL database engine and the most used database engine in the world. + +Originally, Postgres and Redis were chosen as defaults but since the aim of this project is rapid, simple development, it was changed to SQLite which now provides the primary data storage as well as persistent, background [task queues](#tasks). For [caching](#cache), a simple in-memory solution is provided. If you need to use something like Postgres or Redis, swapping those in can be done quickly and easily. For reference, [this branch](https://github.com/mikestefanello/pagoda/tree/postgres-redis) contains the code that included those (but is no longer maintained). ### Screenshots @@ -144,40 +143,27 @@ Go server-side rendered HTML combined with the projects below enable you to crea ### Dependencies -Ensure the following are installed on your system: - - - [Go](https://go.dev/) - - [Docker](https://www.docker.com/) - - [Docker Compose](https://docs.docker.com/compose/install/) +Ensure that [Go](https://go.dev/) is installed on your system. ### Start the application -After checking out the repository, from within the root, start the Docker containers for the database and cache by executing `make up`: +After checking out the repository, from within the root, simply run `make run`: ``` git clone git@github.com:mikestefanello/pagoda.git cd pagoda -make up +make run ``` Since this repository is a _template_ and not a Go _library_, you **do not** use `go get`. -Once that completes, you can start the application by executing `make run`. By default, you should be able to access the application in your browser at `localhost:8000`. +By default, you should be able to access the application in your browser at `localhost:8000`. This can be changed via the [configuration](#configuration). -If you ever want to quickly drop the Docker containers and restart them in order to wipe all data, execute `make reset`. +By default, your data will be stored within the `dbs` directory. If you ever want to quickly delete all data just remove this directory. ### Running tests -To run all tests in the application, execute `make test`. This ensures that the tests from each package are not run in parallel. This is required since many packages contain tests that connect to the test database which is dropped and recreated automatically for each package. - -### Clients - -The following _make_ commands are available to make it easy to connect to the database and cache. - -- `make db`: Connects to the primary database -- `make db-test`: Connects to the test database -- `make cache`: Connects to the primary cache -- `make cache-test`: Connects to the test cache +To run all tests in the application, execute `make test`. This ensures that the tests from each package are not run in parallel. This is required since many packages contain tests that connect to the test database which is stored in memory and reset automatically for each package. ## Service container @@ -198,7 +184,7 @@ A new container can be created and initialized via `services.NewContainer()`. It ### Dependency injection -The container exists to faciliate easy dependency-injection both for services within the container as well as areas of your application that require any of these dependencies. For example, the container is automatically passed to the `Init()` method of your route handlers so that the handlers have full, easy access to all services. +The container exists to faciliate easy dependency-injection both for services within the container as well as areas of your application that require any of these dependencies. For example, the container is automatically passed to the `Init()` method of your route [handlers](#handlers) so that the handlers have full, easy access to all services. ### Test dependencies @@ -217,11 +203,11 @@ Leveraging the functionality of [viper](https://github.com/spf13/viper) to manag In `config/config.go`, the prefix is set as `pagoda` via `viper.SetEnvPrefix("pagoda")`. Nested fields require an underscore between levels. For example: ```yaml -cache: +http: port: 1234 ``` -can be overridden by setting an environment variable with the name `PAGODA_CACHE_PORT`. +can be overridden by setting an environment variable with the name `PAGODA_HTTP_PORT`. ### Environments @@ -251,7 +237,7 @@ func TestMain(m *testing.M) { ## Database -The database currently used is [PostgreSQL](https://www.postgresql.org/) but you are free to use whatever you prefer. If you plan to continue using [Ent](https://entgo.io/), the incredible ORM, you can check their supported databases [here](https://entgo.io/docs/dialects). The database-driver and client is provided by [pgx](https://github.com/jackc/pgx/tree/v4) and included in the `Container`. +The database currently used is [SQLite](https://sqlite.org/) but you are free to use whatever you prefer. If you plan to continue using [Ent](https://entgo.io/), the incredible ORM, you can check their supported databases [here](https://entgo.io/docs/dialects). The database driver is provided by [go-sqlite3](https://github.com/mattn/go-sqlite3). A reference to the database is included in the `Container` if direct access is required. Database configuration can be found and managed within the `config` package. @@ -261,9 +247,11 @@ Database configuration can be found and managed within the `config` package. ### Separate test database -Since many tests can require a database, this application supports a separate database specifically for tests. Within the `config`, the test database name can be specified at `Config.Database.TestDatabase`. +Since many tests can require a database, this application supports a separate database specifically for tests. Within the `config`, the test database can be specified at `Config.Database.TestConnection`, which is the database connection string that will be used. By default, this will be an in-memory SQLite database. -When a `Container` is created, if the [environment](#environments) is set to `config.EnvTest`, the database client will connect to the test database instead, drop the database, recreate it, and run migrations so your tests start with a clean, ready-to-go database. Another benefit is that after the tests execute in a given package, you can connect to the test database to audit the data which can be useful for debugging. +When a `Container` is created, if the [environment](#environments) is set to `config.EnvTest`, the database client will connect to the test database instead and run migrations so your tests start with a clean, ready-to-go database. + +When this project was using Postgres, it would automatically drop and recreate the test database. Since the current default is in-memory, that is no longer needed. If you decide to use a test database not in-memory, you can alter the `Container` initialization code to do this for you. ## ORM @@ -926,13 +914,11 @@ To include additional custom functions, add to the map in `NewFuncMap()` and def ## Cache -As previously mentioned, [Redis](https://redis.io/) was chosen as the cache but it can be easily swapped out for something else. [go-redis](https://github.com/go-redis/redis) is used as the underlying client but the `Container` contains a custom client wrapper (`CacheClient`) that makes typical cache operations extremely simple. This wrapper does expose the [go-redis]() client however, at `CacheClient.Client`, in case you have a need for it. +As previously mentioned, the default cache implementation is a simple in-memory store, backed by [otter](https://github.com/maypok86/otter), a lockless cache that uses [S3-FIFO](https://s3fifo.com/) eviction. The `Container` houses a `CacheClient` which is a useful, wrapper to interact with the cache (see examples below). Within the `CacheClient` is the underlying store interface `CacheStore`. If you wish to use a different store, such as Redis, and want to keep using the `CacheClient`, simply implement the `CacheStore` interface with a Redis library and adjust the `Container` initialization to use that. -The cache functionality within the `CacheClient` is powered by [gocache](https://github.com/eko/gocache) which was chosen because it makes interfacing with the cache service much easier, and it provides a consistent interface if you were to use a cache backend other than Redis. +The built-in usage of the cache is currently only for optional [page caching](#cached-responses) and a simple example route located at `/cache` where you can set and view the value of a given cache entry. -The built-in usage of the cache is currently only for optional [page caching](#cached-responses) but it can be used for practically anything. See examples below: - -Similar to how there is a separate [test database](#separate-test-database) to avoid writing to your primary database when running tests, the cache supports a separate database as well for tests. Within the `config`, the test database number can be specified at `Config.Cache.TestDatabase`. By default, the primary database is `0` and the test database is `1`. +Since the current cache is in-memory, there's no need to adjust the `Container` during tests. When this project used Redis, the configuration had a separate database that would be used strictly for tests to avoid writing to your primary database. If you need that functionality, it is easy to add back in. ### Set data @@ -943,6 +929,7 @@ err := c.Cache. Set(). Key("my-key"). Data(myData). + Expiration(time.Hour * 2). Save(ctx) ``` @@ -953,6 +940,7 @@ err := c.Cache. Set(). Group("my-group"). Key("my-key"). + Expiration(time.Hour * 2). Data(myData). Save(ctx) ``` @@ -964,16 +952,6 @@ err := c.Cache. Set(). Key("my-key"). Tags("tag1", "tag2"). - Data(myData). - Save(ctx) -``` - -**Include an expiration:** - -```go -err := c.Cache. - Set(). - Key("my-key"). Expiration(time.Hour * 2). Data(myData). Save(ctx) @@ -986,12 +964,9 @@ data, err := c.Cache. Get(). Group("my-group"). Key("my-key"). - Type(myType). Fetch(ctx) ``` -The `Type` method tells the cache what type of data you stored so it can be cast afterwards with: `result, ok := data.(myType)` - ### Flush data ```go @@ -1013,29 +988,62 @@ err := c.Cache. Execute(ctx) ``` +### Tagging + +As shown in the previous examples, cache tags were provided because they can be convenient. However, maintaining them comes at a cost and it may not be a good fit for your application depending on your needs. When including tags, the `CacheClient` must lock in order to keep the tag index in sync. And since the tag index cannot support eviction, since that could result in a flush call not actually flushing the tag's keys, the maps that provide the index do not have a size limit. See the code for more details. + ## Tasks -Tasks are operations to be executed in the background, either in a queue, at a specfic time, after a given amount of time, or according to a periodic interval (like _cron_). Some examples of tasks could be long-running operations, bulk processing, cleanup, notifications, and so on. +Tasks are queued operations to be executed in the background, either immediately, at a specfic time, or after a given amount of time has passed. Some examples of tasks could be long-running operations, bulk processing, cleanup, notifications, etc. -Since we're already using [Redis](https://redis.io) as a _cache_, it's available to act as a message broker as well and handle the processing of queued tasks. [Asynq](https://github.com/hibiken/asynq) is the library chosen to interface with Redis and handle queueing tasks and processing them asynchronously with workers. +Since we're already using [SQLite](https://sqlite.org/) for our database, it's available to act as a persistent store for queued tasks so that tasks are never lost, can be retried until successful, and their concurrent execution can be managed. [Goqite](https://github.com/maragudk/goqite) is the library chosen to interface with [SQLite](https://sqlite.org/) and handle queueing tasks and processing them asynchronously. -To make things even easier, a custom client (`TaskClient`) is provided as a _Service_ on the `Container` which exposes a simple interface with [asynq](https://github.com/hibiken/asynq). - -For more detailed information about [asynq](https://github.com/hibiken/asynq) and it's usage, review the [wiki](https://github.com/hibiken/asynq/wiki). +To make things even easier, a custom client (`TaskClient`) is provided as a _Service_ on the `Container` which exposes a simple interface with [goqite](https://github.com/maragudk/goqite) that supports type-safe tasks and queues. ### Queues -All tasks must be placed in to queues in order to be executed by the [worker](#worker). You are not required to specify a queue when creating a task, as it will be placed in the default queue if one is not provided. [Asynq](https://github.com/hibiken/asynq) supports multiple queues which allows for functionality such as [prioritization](https://github.com/hibiken/asynq/wiki/Queue-Priority). +A full example of a queue implementation can be found in `pkg/tasks` with an interactive form to create a task and add to the queue at `/task` (see `pkg/handlers/task.go`). -Creating a queued task is easy and at the minimum only requires the name of the task: +A queue starts by declaring a `Task` _type_, which is the object that gets placed in to a queue and eventually passed to a queue subscriber (a callback function to process the task). A `Task` must implement the `Name()` method which returns a unique name for the task. For example: ```go -err := c.Tasks. - New("my_task"). - Save() +type MyTask struct { + Text string + Num int +} + +func (t MyTask) Name() string { + return "my_task" +} ``` -This will add a task to the _default_ queue with a task _type_ of `my_task`. The type is used to route the task to the correct [worker](#worker). +Then, create the queue for `MyTask` tasks: + +```go +q := services.NewQueue[MyTask](func(ctx context.Context, task MyTask) error { + // This is where you process the task + fmt.Println("Processed %s task!", task.Text) + return nil +}) +``` + +And finally, register the queue with the `TaskClient`: + +```go +c.Tasks.Register(q) +``` + +See `pkg/tasks/register.go` for a simple way to register all of your queues and to easily pass the `Container` to them so the queue subscriber callbacks have access to all of your app's dependencies. + +Now you can easily add a task to the queue using the `TaskClient`: + +```go +task := MyTask{Text: "Hello world!", Num: 10} + +err := c.Tasks. + New(task). + Save() +``` #### Options @@ -1043,98 +1051,26 @@ Tasks can be created and queued with various chained options: ```go err := c.Tasks. - New("my_task"). - Payload(taskData). - Queue("critical"). - MaxRetries(5). - Timeout(30 * time.Second). - Wait(5 * time.Second). - Retain(2 * time.Hour). + New(task). + Wait(30 * time.Second). // Wait 30 seconds before passing the task to the subscriber + At(time.Date(...)). // Wait until a given date before passing the task to the subscriber + Tx(tx). // Include the queueing of this task in a database transaction Save() ``` -In this example, this task will be: -- Assigned a task type of `my_task` -- The task worker will be sent `taskData` as the payload -- Put in to the `critical` queue -- Be retried up to 5 times in the event of a failure -- Timeout after 30 seconds of execution -- Wait 5 seconds before execution starts -- Retain the task data in Redis for 2 hours after execution completes +### Runner -### Scheduled tasks - -Tasks can be scheduled to execute at a single point in the future or at a periodic interval. These tasks can also use the options highlighted in the previous section. - -**To execute a task once at a specific time:** +The _task runner_ is what manages periodically polling the database for available queued tasks to process and passing them to the queue's subscriber callback. This must be started in order for this to happen. In `cmd/web/main.go`, the _task runner_ is started by using the `TaskClient`: ```go -err := c.Tasks. - New("my_task"). - At(time.Date(2022, time.November, 10, 23, 0, 0, 0, time.UTC)). - Save() +go c.Tasks.StartRunner(ctx) ``` -**To execute a periodic task using a cron schedule:** +The app [configuration](#configuration) contains values to configure the runner including how often to poll the database for tasks, the maximum amount of retries for a given task, and the amount of tasks that can be processed concurrently. -```go -err := c.Tasks. - New("my_task"). - Periodic("*/10 * * * *") - Save() -``` +## Cron -**To execute a periodic task using a simple syntax:** - -```go -err := c.Tasks. - New("my_task"). - Periodic("@every 10m") - Save() -``` - -#### Scheduler - -A service needs to run in order to add periodic tasks to the queue at the specified intervals. When the application is started, this _scheduler_ service will also be started. In `cmd/web/main.go`, this is done with the following code: - -```go -go func() { - if err := c.Tasks.StartScheduler(); err != nil { - log.Fatalf("scheduler shutdown: %v", err) - } -}() -``` - -In the event of an application restart, periodic tasks must be re-registered with the _scheduler_ in order to continue being queued for execution. - -### Worker - -The worker is a service that executes the queued tasks using task processors. Included is a basic implementation of a separate worker service that will listen for and execute tasks being added to the queues. If you prefer to move the worker so it runs alongside the web server, you can do that, though it's recommended to keep these processes separate for performance and scalability reasons. - -The underlying functionality of the worker service is provided by [asynq](https://github.com/hibiken/asynq), so it's highly recommended that you review the documentation for that project first. - -#### Starting the worker - -A make target was added to allow you to start the worker service easily. From the root of the repository, execute `make worker`. - -#### Understanding the service - -The worker service is located in [cmd/worker/main.go](/cmd/worker/main.go) and starts with the creation of a new `*asynq.Server` provided by `asynq.NewServer()`. There are various configuration options available, so be sure to review them all. - -Prior to starting the service, we need to route tasks according to their _type_ to their handlers which will process the tasks. This is done by using `async.ServeMux` much like you would use an HTTP router: - -```go -mux := asynq.NewServeMux() -mux.Handle(tasks.TypeExample, new(tasks.ExampleProcessor)) -``` - -In this example, all tasks of _type_ `tasks.TypeExample` will be routed to `ExampleProcessor` which is a struct that implements `ProcessTask()`. See the included [basic example](/pkg/tasks/example.go). - -Finally, the service is started with `async.Server.Run(mux)`. - -### Monitoring - -[Asynq](https://github.com/hibiken/asynq) comes with two options to monitor your queues: 1) [Command-line tool](https://github.com/hibiken/asynq#command-line-tool) and 2) [Web UI](https://github.com/hibiken/asynqmon) +By default, no cron solution is provided because it's very easy to add yourself if you need this. You can either use a [ticker](https://pkg.go.dev/time#Ticker) or a [library](https://github.com/robfig/cron). ## Static files @@ -1266,22 +1202,19 @@ Future work includes but is not limited to: Thank you to all of the following amazing projects for making this possible. - [alpinejs](https://github.com/alpinejs/alpine) -- [asynq](https://github.com/hibiken/asynq) - [bulma](https://github.com/jgthms/bulma) -- [docker](https://www.docker.com/) - [echo](https://github.com/labstack/echo) - [ent](https://github.com/ent/ent) - [go](https://go.dev/) -- [gocache](https://github.com/eko/gocache) +- [go-sqlite3](https://github.com/mattn/go-sqlite3) +- [goqite](https://github.com/maragudk/goqite) - [goquery](https://github.com/PuerkitoBio/goquery) -- [go-redis](https://github.com/go-redis/redis) - [htmx](https://github.com/bigskysoftware/htmx) - [jwt](https://github.com/golang-jwt/jwt) -- [pgx](https://github.com/jackc/pgx) -- [postgresql](https://www.postgresql.org/) -- [redis](https://redis.io/) -- [sprig](https://github.com/Masterminds/sprig) +- [otter](https://github.com/maypok86/otter) - [sessions](https://github.com/gorilla/sessions) +- [sprig](https://github.com/Masterminds/sprig) +- [sqlite](https://sqlite.org/) - [testify](https://github.com/stretchr/testify) - [validator](https://github.com/go-playground/validator) -- [viper](https://github.com/spf13/viper) +- [viper](https://github.com/spf13/viper) \ No newline at end of file diff --git a/cmd/web/main.go b/cmd/web/main.go index 1d8045b..f07d0c7 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/tls" + "errors" "fmt" "log" "net/http" @@ -12,6 +13,7 @@ import ( "github.com/mikestefanello/pagoda/pkg/handlers" "github.com/mikestefanello/pagoda/pkg/services" + "github.com/mikestefanello/pagoda/pkg/tasks" ) func main() { @@ -49,24 +51,25 @@ func main() { } } - if err := c.Web.StartServer(&srv); err != http.ErrServerClosed { + if err := c.Web.StartServer(&srv); errors.Is(err, http.ErrServerClosed) { log.Fatalf("shutting down the server: %v", err) } }() - // Start the scheduler service to queue periodic tasks - go func() { - if err := c.Tasks.StartScheduler(); err != nil { - log.Fatalf("scheduler shutdown: %v", err) - } - }() + // Register all task queues + tasks.Register(c) - // Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds. + // Start the task runner to execute queued tasks + ctx, cancel := context.WithCancel(context.Background()) + go c.Tasks.StartRunner(ctx) + + // Wait for interrupt signal to gracefully shut down the server with a timeout of 10 seconds. quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) signal.Notify(quit, os.Kill) <-quit - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + cancel() + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := c.Web.Shutdown(ctx); err != nil { log.Fatal(err) diff --git a/cmd/worker/main.go b/cmd/worker/main.go deleted file mode 100644 index 072e5b5..0000000 --- a/cmd/worker/main.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "fmt" - "log" - - "github.com/hibiken/asynq" - "github.com/mikestefanello/pagoda/config" - "github.com/mikestefanello/pagoda/pkg/tasks" -) - -func main() { - // Load the configuration - cfg, err := config.GetConfig() - if err != nil { - panic(fmt.Sprintf("failed to load config: %v", err)) - } - - // Build the worker server - srv := asynq.NewServer( - asynq.RedisClientOpt{ - Addr: fmt.Sprintf("%s:%d", cfg.Cache.Hostname, cfg.Cache.Port), - DB: cfg.Cache.Database, - Password: cfg.Cache.Password, - }, - asynq.Config{ - // See asynq.Config for all available options and explanation - Concurrency: 10, - Queues: map[string]int{ - "critical": 6, - "default": 3, - "low": 1, - }, - }, - ) - - // Map task types to the handlers - mux := asynq.NewServeMux() - mux.Handle(tasks.TypeExample, new(tasks.ExampleProcessor)) - - // Start the worker server - if err := srv.Run(mux); err != nil { - log.Fatalf("could not run worker server: %v", err) - } -} diff --git a/config/config.go b/config/config.go index b17ef24..b868259 100644 --- a/config/config.go +++ b/config/config.go @@ -57,6 +57,7 @@ type ( App AppConfig Cache CacheConfig Database DatabaseConfig + Tasks TasksConfig Mail MailConfig } @@ -89,12 +90,8 @@ type ( // CacheConfig stores the cache configuration CacheConfig struct { - Hostname string - Port uint16 - Password string - Database int - TestDatabase int - Expiration struct { + Capacity int + Expiration struct { StaticFile time.Duration Page time.Duration } @@ -102,12 +99,16 @@ type ( // DatabaseConfig stores the database configuration DatabaseConfig struct { - Hostname string - Port uint16 - User string - Password string - Database string - TestDatabase string + Driver string + Connection string + TestConnection string + } + + // TasksConfig stores the tasks configuration + TasksConfig struct { + PollInterval time.Duration + MaxRetries int + Goroutines int } // MailConfig stores the mail configuration diff --git a/config/config.yaml b/config/config.yaml index a43a6bf..8d6c305 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -21,22 +21,20 @@ app: emailVerificationTokenExpiration: "12h" cache: - hostname: "localhost" - port: 6379 - password: "" - database: 0 - testDatabase: 1 + capacity: 100000 expiration: staticFile: "4380h" page: "24h" database: - hostname: "localhost" - port: 5432 - user: "admin" - password: "admin" - database: "app" - testDatabase: "app_test" + driver: "sqlite3" + connection: "dbs/main.db?_journal=WAL&_timeout=5000&_fk=true" + testConnection: ":memory:?_journal=WAL&_timeout=5000&_fk=true" + +tasks: + pollInterval: "1s" + maxRetries: 10 + goroutines: 1 mail: hostname: "localhost" diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index fedc25a..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,18 +0,0 @@ -version: "3" - -services: - cache: - image: "redis:alpine" - container_name: pagoda_cache - ports: - - "127.0.0.1:6379:6379" - db: - # PG 16 is currently not supported https://github.com/ent/ent/issues/3750 - image: postgres:15-alpine - container_name: pagoda_db - ports: - - "127.0.0.1:5432:5432" - environment: - - POSTGRES_USER=admin - - POSTGRES_PASSWORD=admin - - POSTGRES_DB=app diff --git a/go.mod b/go.mod index fd052b6..7e669be 100644 --- a/go.mod +++ b/go.mod @@ -8,17 +8,15 @@ require ( entgo.io/ent v0.13.1 github.com/Masterminds/sprig v2.22.0+incompatible github.com/PuerkitoBio/goquery v1.9.1 - github.com/eko/gocache/lib/v4 v4.1.6 - github.com/eko/gocache/store/redis/v4 v4.2.1 github.com/go-playground/validator/v10 v10.19.0 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/gorilla/context v1.1.2 github.com/gorilla/sessions v1.2.2 - github.com/hibiken/asynq v0.24.1 - github.com/jackc/pgx/v4 v4.18.3 github.com/labstack/echo/v4 v4.12.0 github.com/labstack/gommon v0.4.2 - github.com/redis/go-redis/v9 v9.5.1 + github.com/maragudk/goqite v0.2.3 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/maypok86/otter v1.2.1 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.22.0 @@ -31,17 +29,14 @@ require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dolthub/maphash v0.1.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gammazero/deque v0.2.1 // indirect github.com/go-openapi/inflect v0.21.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/golang/mock v1.6.0 // indirect - github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -49,13 +44,6 @@ require ( github.com/hashicorp/hcl/v2 v2.20.1 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/imdario/mergo v0.3.16 // indirect - github.com/jackc/chunkreader/v2 v2.0.1 // indirect - github.com/jackc/pgconn v1.14.3 // indirect - github.com/jackc/pgio v1.0.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgproto3/v2 v2.3.3 // indirect - github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect - github.com/jackc/pgtype v1.14.3 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -66,11 +54,7 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.19.0 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.53.0 // indirect - github.com/prometheus/procfs v0.14.0 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -80,8 +64,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect - github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/zclconf/go-cty v1.14.4 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect @@ -92,7 +74,7 @@ require ( golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.20.0 // indirect - google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3d70492..f3751f8 100644 --- a/go.sum +++ b/go.sum @@ -2,15 +2,12 @@ ariga.io/atlas v0.21.1 h1:Eg9XYhKTH3UHoqP7tKMWFV+Z5JnpVOJCgO3MHrUtKmk= ariga.io/atlas v0.21.1/go.mod h1:VPlcXdd4w2KqKnH54yEZcry79UAhpaWaxEsmn5JRNoE= entgo.io/ent v0.13.1 h1:uD8QwN1h6SNphdCCzmkMN3feSUzNnVvV/WIkHKMbzOE= entgo.io/ent v0.13.1/go.mod h1:qCEmo+biw3ccBn9OyL4ZK5dfpwg++l1Gxwac5B1206A= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= @@ -21,40 +18,20 @@ github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsVi github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= -github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/eko/gocache/lib/v4 v4.1.6 h1:5WWIGISKhE7mfkyF+SJyWwqa4Dp2mkdX8QsZpnENqJI= -github.com/eko/gocache/lib/v4 v4.1.6/go.mod h1:HFxC8IiG2WeRotg09xEnPD72sCheJiTSr4Li5Ameg7g= -github.com/eko/gocache/store/redis/v4 v4.2.1 h1:uPAgZIn7knH6a55tO4ETN9V93VD3Rcyx0ZIyozEqC0I= -github.com/eko/gocache/store/redis/v4 v4.2.1/go.mod h1:JoLkNA5yeGNQUwINAM9529cDNQCo88WwiKlO9e/+39I= +github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= +github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/gammazero/deque v0.2.1 h1:qSdsbG6pgp6nL7A0+K/B7s12mcCY/5l5SIUpMOl+dC0= +github.com/gammazero/deque v0.2.1/go.mod h1:LFroj8x4cMYCukHJDbxFCkT+r9AndaJnFMuZDV34tuU= github.com/go-openapi/inflect v0.21.0 h1:FoBjBTQEcbg2cJUWX6uwL9OyIW8eqc9k4KhN4lfbeYk= github.com/go-openapi/inflect v0.21.0/go.mod h1:INezMuUu7SJQc2AyR3WO0DqqYUJSj8Kb4hBd7WtjlAw= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -65,27 +42,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4= github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= -github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= -github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= @@ -98,71 +62,14 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc= github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4= -github.com/hibiken/asynq v0.24.1 h1:+5iIEAyA9K/lcSPvx3qoPtsKJeKI5u9aOIvUmSsazEw= -github.com/hibiken/asynq v0.24.1/go.mod h1:u5qVeSbrnfT+vtG5Mq8ZPzQu/BmCKMHvTGb91uy9Tts= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= -github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= -github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= -github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= -github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= -github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= -github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= -github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= -github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= -github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= -github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= -github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= -github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= -github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= -github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= -github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= -github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= -github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= -github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= -github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= -github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= -github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= -github.com/jackc/pgtype v1.14.3 h1:h6W9cPuHsRWQFTWUZMAKMgG5jSwQI0Zurzdvlx3Plus= -github.com/jackc/pgtype v1.14.3/go.mod h1:aKeozOde08iifGosdJpz9MBZonJOUJxqNpPBcMJTlVA= -github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= -github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= -github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= -github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= -github.com/jackc/pgx/v4 v4.18.2/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= -github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA= -github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw= -github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= @@ -172,25 +79,21 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0 github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= -github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/maragudk/goqite v0.2.3 h1:R8oVD6IMCQfjhCKyGIYwWxR1w8yxjvT/3uwYtA656jE= +github.com/maragudk/goqite v0.2.3/go.mod h1:5430TCLkycUeLE314c9fifTrTbwcJqJXdU3iyEiF6hM= +github.com/maragudk/is v0.1.0 h1:obq9anZNmOYcaNbeT0LMyjIexdNeYTw/TLAPD/BnZHA= +github.com/maragudk/is v0.1.0/go.mod h1:W/r6+TpnISu+a88OLXQy5JQGCOhXQXXLD2e5b4xMn5c= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maypok86/otter v1.2.1 h1:xyvMW+t0vE1sKt/++GTkznLitEl7D/msqXkAbLwiC1M= +github.com/maypok86/otter v1.2.1/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= @@ -201,46 +104,19 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= -github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= -github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= -github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s= -github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ= -github.com/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= -github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= -github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= -github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= -github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= -github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -248,19 +124,11 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -270,156 +138,71 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= -github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= -github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= -github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b h1:FosyBZYxY34Wul7O/MSKey3txpPYyCqVO5ZyceuQJEI= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= -github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA= -go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= -golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/pkg/form/submission.go b/pkg/form/submission.go index 7e5513a..88d898f 100644 --- a/pkg/form/submission.go +++ b/pkg/form/submission.go @@ -103,6 +103,8 @@ func (f *Submission) setErrorMessages(err error) { message = "Enter a valid email address." case "eqfield": message = "Does not match." + case "gte": + message = fmt.Sprintf("Must be greater than or equal to %v.", ve.Param()) default: message = "Invalid value." } diff --git a/pkg/handlers/cache.go b/pkg/handlers/cache.go new file mode 100644 index 0000000..a0600f4 --- /dev/null +++ b/pkg/handlers/cache.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "errors" + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/services" + "github.com/mikestefanello/pagoda/templates" + "time" +) + +const ( + routeNameCache = "cache" + routeNameCacheSubmit = "cache.submit" +) + +type ( + Cache struct { + cache *services.CacheClient + *services.TemplateRenderer + } + + cacheForm struct { + Value string `form:"value"` + form.Submission + } +) + +func init() { + Register(new(Cache)) +} + +func (h *Cache) Init(c *services.Container) error { + h.TemplateRenderer = c.TemplateRenderer + h.cache = c.Cache + return nil +} + +func (h *Cache) Routes(g *echo.Group) { + g.GET("/cache", h.Page).Name = routeNameCache + g.POST("/cache", h.Submit).Name = routeNameCacheSubmit +} + +func (h *Cache) Page(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutMain + p.Name = templates.PageCache + p.Title = "Set a cache entry" + p.Form = form.Get[cacheForm](ctx) + + // Fetch the value from the cache + value, err := h.cache. + Get(). + Key("page_cache_example"). + Fetch(ctx.Request().Context()) + + // Store the value in the page, so it can be rendered, if found + switch { + case err == nil: + p.Data = value.(string) + case errors.Is(err, services.ErrCacheMiss): + default: + return fail(err, "failed to fetch from cache") + } + + return h.RenderPage(ctx, p) +} + +func (h *Cache) Submit(ctx echo.Context) error { + var input cacheForm + + if err := form.Submit(ctx, &input); err != nil { + return err + } + + // Set the cache + err := h.cache. + Set(). + Key("page_cache_example"). + Data(input.Value). + Expiration(30 * time.Minute). + Save(ctx.Request().Context()) + + if err != nil { + return fail(err, "unable to set cache") + } + + form.Clear(ctx) + + return h.Page(ctx) +} diff --git a/pkg/handlers/contact.go b/pkg/handlers/contact.go index 16a5bfb..4c5f264 100644 --- a/pkg/handlers/contact.go +++ b/pkg/handlers/contact.go @@ -2,7 +2,6 @@ package handlers import ( "fmt" - "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/pkg/form" diff --git a/pkg/handlers/task.go b/pkg/handlers/task.go new file mode 100644 index 0000000..f4abc64 --- /dev/null +++ b/pkg/handlers/task.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "fmt" + "github.com/mikestefanello/pagoda/pkg/msg" + "time" + + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/pkg/form" + "github.com/mikestefanello/pagoda/pkg/page" + "github.com/mikestefanello/pagoda/pkg/services" + "github.com/mikestefanello/pagoda/pkg/tasks" + "github.com/mikestefanello/pagoda/templates" +) + +const ( + routeNameTask = "task" + routeNameTaskSubmit = "task.submit" +) + +type ( + Task struct { + tasks *services.TaskClient + *services.TemplateRenderer + } + + taskForm struct { + Delay int `form:"delay" validate:"gte=0"` + Message string `form:"message" validate:"required"` + form.Submission + } +) + +func init() { + Register(new(Task)) +} + +func (h *Task) Init(c *services.Container) error { + h.TemplateRenderer = c.TemplateRenderer + h.tasks = c.Tasks + return nil +} + +func (h *Task) Routes(g *echo.Group) { + g.GET("/task", h.Page).Name = routeNameTask + g.POST("/task", h.Submit).Name = routeNameTaskSubmit +} + +func (h *Task) Page(ctx echo.Context) error { + p := page.New(ctx) + p.Layout = templates.LayoutMain + p.Name = templates.PageTask + p.Title = "Create a task" + p.Form = form.Get[taskForm](ctx) + + return h.RenderPage(ctx, p) +} + +func (h *Task) Submit(ctx echo.Context) error { + var input taskForm + + err := form.Submit(ctx, &input) + + switch err.(type) { + case nil: + case validator.ValidationErrors: + return h.Page(ctx) + default: + return err + } + + // Insert the task + err = h.tasks.New(tasks.ExampleTask{ + Message: input.Message, + }). + Wait(time.Duration(input.Delay) * time.Second). + Save() + + if err != nil { + return fail(err, "unable to create a task") + } + + msg.Success(ctx, fmt.Sprintf("The task has been created. Check the logs in %d seconds.", input.Delay)) + form.Clear(ctx) + + return h.Page(ctx) +} diff --git a/pkg/middleware/cache.go b/pkg/middleware/cache.go index fcfa43b..1701dc2 100644 --- a/pkg/middleware/cache.go +++ b/pkg/middleware/cache.go @@ -10,7 +10,6 @@ import ( "github.com/mikestefanello/pagoda/pkg/log" "github.com/mikestefanello/pagoda/pkg/services" - libstore "github.com/eko/gocache/lib/v4/store" "github.com/labstack/echo/v4" ) @@ -35,7 +34,7 @@ func ServeCachedPage(t *services.TemplateRenderer) echo.MiddlewareFunc { if err != nil { switch { - case errors.Is(err, &libstore.NotFound{}): + case errors.Is(err, services.ErrCacheMiss): case context.IsCanceledError(err): return nil default: diff --git a/pkg/services/cache.go b/pkg/services/cache.go index ec38c83..331ef75 100644 --- a/pkg/services/cache.go +++ b/pkg/services/cache.go @@ -4,28 +4,39 @@ import ( "context" "errors" "fmt" + "sync" "time" - "github.com/eko/gocache/lib/v4/cache" - "github.com/eko/gocache/lib/v4/marshaler" - libstore "github.com/eko/gocache/lib/v4/store" - redisstore "github.com/eko/gocache/store/redis/v4" - "github.com/mikestefanello/pagoda/config" - "github.com/redis/go-redis/v9" + "github.com/maypok86/otter" ) -type ( - // CacheClient is the client that allows you to interact with the cache - CacheClient struct { - // Client stores the client to the underlying cache service - Client *redis.Client +// ErrCacheMiss indicates that the requested key does not exist in the cache +var ErrCacheMiss = errors.New("cache miss") - // cache stores the cache interface - cache *cache.Cache[any] +type ( + // CacheStore provides an interface for cache storage + CacheStore interface { + // get attempts to get a cached value + get(context.Context, *CacheGetOp) (any, error) + + // set attempts to set an entry in the cache + set(context.Context, *CacheSetOp) error + + // flush removes a given key and/or tags from the cache + flush(context.Context, *CacheFlushOp) error + + // close shuts down the cache storage + close() } - // cacheSet handles chaining a set operation - cacheSet struct { + // CacheClient is the client that allows you to interact with the cache + CacheClient struct { + // store holds the Cache storage + store CacheStore + } + + // CacheSetOp handles chaining a set operation + CacheSetOp struct { client *CacheClient key string group string @@ -34,76 +45,69 @@ type ( tags []string } - // cacheGet handles chaining a get operation - cacheGet struct { - client *CacheClient - key string - group string - dataType any + // CacheGetOp handles chaining a get operation + CacheGetOp struct { + client *CacheClient + key string + group string } - // cacheFlush handles chaining a flush operation - cacheFlush struct { + // CacheFlushOp handles chaining a flush operation + CacheFlushOp struct { client *CacheClient key string group string tags []string } + + // inMemoryCacheStore is a cache store implementation in memory + inMemoryCacheStore struct { + store *otter.CacheWithVariableTTL[string, any] + tagIndex *tagIndex + } + + // tagIndex maintains an index to support cache tags for in-memory cache stores. + // There is a performance and memory impact to using cache tags since set and get operations using tags will require + // locking, and we need to keep track of this index in order to keep everything in sync. + // If using something like Redis for caching, you can leverage sets to store the index. + // Cache tags can be useful and convenient, so you should decide if your app benefits enough from this. + // As it stands here, there is no limiting how much memory this will consume and it will track all keys + // and tags added and removed from the cache. You could store these in the cache itself but allowing these to + // be evicted poses challenges. + tagIndex struct { + sync.Mutex + tags map[string]map[string]struct{} // tag->keys + keys map[string]map[string]struct{} // key->tags + } ) // NewCacheClient creates a new cache client -func NewCacheClient(cfg *config.Config) (*CacheClient, error) { - // Determine the database based on the environment - db := cfg.Cache.Database - if cfg.App.Environment == config.EnvTest { - db = cfg.Cache.TestDatabase - } - - // Connect to the cache - c := &CacheClient{} - c.Client = redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%d", cfg.Cache.Hostname, cfg.Cache.Port), - Password: cfg.Cache.Password, - DB: db, - }) - if _, err := c.Client.Ping(context.Background()).Result(); err != nil { - return c, err - } - - // Flush the database if this is the test environment - if cfg.App.Environment == config.EnvTest { - if err := c.Client.FlushDB(context.Background()).Err(); err != nil { - return c, err - } - } - - cacheStore := redisstore.NewRedis(c.Client) - c.cache = cache.New[any](cacheStore) - return c, nil +func NewCacheClient(store CacheStore) *CacheClient { + return &CacheClient{store: store} } // Close closes the connection to the cache -func (c *CacheClient) Close() error { - return c.Client.Close() +func (c *CacheClient) Close() { + c.store.close() } // Set creates a cache set operation -func (c *CacheClient) Set() *cacheSet { - return &cacheSet{ +func (c *CacheClient) Set() *CacheSetOp { + return &CacheSetOp{ client: c, } } // Get creates a cache get operation -func (c *CacheClient) Get() *cacheGet { - return &cacheGet{ +func (c *CacheClient) Get() *CacheGetOp { + return &CacheGetOp{ client: c, } } // Flush creates a cache flush operation -func (c *CacheClient) Flush() *cacheFlush { - return &cacheFlush{ +func (c *CacheClient) Flush() *CacheFlushOp { + return &CacheFlushOp{ client: c, } } @@ -117,111 +121,231 @@ func (c *CacheClient) cacheKey(group, key string) string { } // Key sets the cache key -func (c *cacheSet) Key(key string) *cacheSet { +func (c *CacheSetOp) Key(key string) *CacheSetOp { c.key = key return c } // Group sets the cache group -func (c *cacheSet) Group(group string) *cacheSet { +func (c *CacheSetOp) Group(group string) *CacheSetOp { c.group = group return c } // Data sets the data to cache -func (c *cacheSet) Data(data any) *cacheSet { +func (c *CacheSetOp) Data(data any) *CacheSetOp { c.data = data return c } // Expiration sets the expiration duration of the cached data -func (c *cacheSet) Expiration(expiration time.Duration) *cacheSet { +func (c *CacheSetOp) Expiration(expiration time.Duration) *CacheSetOp { c.expiration = expiration return c } // Tags sets the cache tags -func (c *cacheSet) Tags(tags ...string) *cacheSet { +func (c *CacheSetOp) Tags(tags ...string) *CacheSetOp { c.tags = tags return c } // Save saves the data in the cache -func (c *cacheSet) Save(ctx context.Context) error { - if c.key == "" { +func (c *CacheSetOp) Save(ctx context.Context) error { + switch { + case c.key == "": return errors.New("no cache key specified") + case c.data == nil: + return errors.New("no cache data specified") + case c.expiration == 0: + return errors.New("no cache expiration specified") } - opts := []libstore.Option{ - libstore.WithExpiration(c.expiration), - libstore.WithTags(c.tags), - } - - return marshaler. - New(c.client.cache). - Set(ctx, c.client.cacheKey(c.group, c.key), c.data, opts...) + return c.client.store.set(ctx, c) } // Key sets the cache key -func (c *cacheGet) Key(key string) *cacheGet { +func (c *CacheGetOp) Key(key string) *CacheGetOp { c.key = key return c } // Group sets the cache group -func (c *cacheGet) Group(group string) *cacheGet { +func (c *CacheGetOp) Group(group string) *CacheGetOp { c.group = group return c } -// Type sets the expected Go type of the data being retrieved from the cache -func (c *cacheGet) Type(expectedType any) *cacheGet { - c.dataType = expectedType - return c -} - // Fetch fetches the data from the cache -func (c *cacheGet) Fetch(ctx context.Context) (any, error) { +func (c *CacheGetOp) Fetch(ctx context.Context) (any, error) { if c.key == "" { return nil, errors.New("no cache key specified") } - return marshaler.New(c.client.cache).Get( - ctx, - c.client.cacheKey(c.group, c.key), - c.dataType, - ) + return c.client.store.get(ctx, c) } // Key sets the cache key -func (c *cacheFlush) Key(key string) *cacheFlush { +func (c *CacheFlushOp) Key(key string) *CacheFlushOp { c.key = key return c } // Group sets the cache group -func (c *cacheFlush) Group(group string) *cacheFlush { +func (c *CacheFlushOp) Group(group string) *CacheFlushOp { c.group = group return c } // Tags sets the cache tags -func (c *cacheFlush) Tags(tags ...string) *cacheFlush { +func (c *CacheFlushOp) Tags(tags ...string) *CacheFlushOp { c.tags = tags return c } // Execute flushes the data from the cache -func (c *cacheFlush) Execute(ctx context.Context) error { - if len(c.tags) > 0 { - if err := c.client.cache.Invalidate(ctx, libstore.WithInvalidateTags(c.tags)); err != nil { - return err - } +func (c *CacheFlushOp) Execute(ctx context.Context) error { + return c.client.store.flush(ctx, c) +} + +// newInMemoryCache creates a new in-memory CacheStore +func newInMemoryCache(capacity int) (CacheStore, error) { + s := &inMemoryCacheStore{ + tagIndex: newTagIndex(), } - if c.key != "" { - return c.client.cache.Delete(ctx, c.client.cacheKey(c.group, c.key)) + store, err := otter.MustBuilder[string, any](capacity). + WithVariableTTL(). + DeletionListener(func(key string, value any, cause otter.DeletionCause) { + s.tagIndex.purgeKeys(key) + }). + Build() + + if err != nil { + return nil, err + } + + s.store = &store + + return s, nil +} + +func (s *inMemoryCacheStore) get(_ context.Context, op *CacheGetOp) (any, error) { + v, exists := s.store.Get(op.client.cacheKey(op.group, op.key)) + + if !exists { + return nil, ErrCacheMiss + } + + return v, nil +} + +func (s *inMemoryCacheStore) set(_ context.Context, op *CacheSetOp) error { + key := op.client.cacheKey(op.group, op.key) + + added := s.store.Set( + key, + op.data, + op.expiration, + ) + + if len(op.tags) > 0 { + s.tagIndex.setTags(key, op.tags...) + } + + if !added { + return errors.New("cache set failed") } return nil } + +func (s *inMemoryCacheStore) flush(_ context.Context, op *CacheFlushOp) error { + keys := make([]string, 0) + + if key := op.client.cacheKey(op.group, op.key); key != "" { + keys = append(keys, key) + } + + if len(op.tags) > 0 { + keys = append(keys, s.tagIndex.purgeTags(op.tags...)...) + } + + for _, key := range keys { + s.store.Delete(key) + } + + s.tagIndex.purgeKeys(keys...) + + return nil +} + +func (s *inMemoryCacheStore) close() { + s.store.Close() +} + +func newTagIndex() *tagIndex { + return &tagIndex{ + tags: make(map[string]map[string]struct{}), + keys: make(map[string]map[string]struct{}), + } +} + +func (i *tagIndex) setTags(key string, tags ...string) { + i.Lock() + defer i.Unlock() + + if _, exists := i.keys[key]; !exists { + i.keys[key] = make(map[string]struct{}) + } + + for _, tag := range tags { + if _, exists := i.tags[tag]; !exists { + i.tags[tag] = make(map[string]struct{}) + } + i.tags[tag][key] = struct{}{} + i.keys[key][tag] = struct{}{} + } +} + +func (i *tagIndex) purgeTags(tags ...string) []string { + i.Lock() + defer i.Unlock() + + keys := make([]string, 0) + + for _, tag := range tags { + if tagKeys, exists := i.tags[tag]; exists { + delete(i.tags, tag) + + for key := range tagKeys { + delete(i.keys[key], tag) + if len(i.keys[key]) == 0 { + delete(i.keys, key) + } + + keys = append(keys, key) + } + } + } + + return keys +} + +func (i *tagIndex) purgeKeys(keys ...string) { + i.Lock() + defer i.Unlock() + + for _, key := range keys { + if keyTags, exists := i.keys[key]; exists { + delete(i.keys, key) + + for tag := range keyTags { + delete(i.tags[tag], key) + if len(i.tags[tag]) == 0 { + delete(i.tags, tag) + } + } + } + } +} diff --git a/pkg/services/cache_test.go b/pkg/services/cache_test.go index 0995b8a..fdaa308 100644 --- a/pkg/services/cache_test.go +++ b/pkg/services/cache_test.go @@ -2,11 +2,9 @@ package services import ( "context" - "errors" "testing" "time" - libstore "github.com/eko/gocache/lib/v4/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,6 +13,7 @@ func TestCacheClient(t *testing.T) { type cacheTest struct { Value string } + // Cache some data data := cacheTest{Value: "abcdef"} group := "testgroup" @@ -24,6 +23,7 @@ func TestCacheClient(t *testing.T) { Group(group). Key(key). Data(data). + Expiration(500 * time.Millisecond). Save(context.Background()) require.NoError(t, err) @@ -32,20 +32,18 @@ func TestCacheClient(t *testing.T) { Get(). Group(group). Key(key). - Type(new(cacheTest)). Fetch(context.Background()) require.NoError(t, err) - cast, ok := fromCache.(*cacheTest) + cast, ok := fromCache.(cacheTest) require.True(t, ok) - assert.Equal(t, data, *cast) + assert.Equal(t, data, cast) // The same key with the wrong group should fail _, err = c.Cache. Get(). Key(key). - Type(new(cacheTest)). Fetch(context.Background()) - assert.Error(t, err) + assert.Equal(t, ErrCacheMiss, err) // Flush the data err = c.Cache. @@ -56,29 +54,42 @@ func TestCacheClient(t *testing.T) { require.NoError(t, err) // The data should be gone - assertFlushed := func() { + assertFlushed := func(key string) { // The data should be gone _, err = c.Cache. Get(). Group(group). Key(key). - Type(new(cacheTest)). Fetch(context.Background()) - assert.True(t, errors.Is(err, &libstore.NotFound{})) + assert.Equal(t, ErrCacheMiss, err) } - assertFlushed() + assertFlushed(key) // Set with tags + key = "testkey2" err = c.Cache. Set(). Group(group). Key(key). Data(data). - Tags("tag1"). + Tags("tag1", "tag2"). + Expiration(time.Hour). Save(context.Background()) require.NoError(t, err) - // Flush the tag + // Check the tag index + index := c.Cache.store.(*inMemoryCacheStore).tagIndex + gk := c.Cache.cacheKey(group, key) + _, exists := index.tags["tag1"][gk] + assert.True(t, exists) + _, exists = index.tags["tag2"][gk] + assert.True(t, exists) + _, exists = index.keys[gk]["tag1"] + assert.True(t, exists) + _, exists = index.keys[gk]["tag2"] + assert.True(t, exists) + + // Flush one of tags err = c.Cache. Flush(). Tags("tag1"). @@ -86,21 +97,9 @@ func TestCacheClient(t *testing.T) { require.NoError(t, err) // The data should be gone - assertFlushed() + assertFlushed(key) - // Set with expiration - err = c.Cache. - Set(). - Group(group). - Key(key). - Data(data). - Expiration(time.Millisecond). - Save(context.Background()) - require.NoError(t, err) - - // Wait for expiration - time.Sleep(time.Millisecond * 2) - - // The data should be gone - assertFlushed() + // The index should be empty + assert.Empty(t, index.tags) + assert.Empty(t, index.keys) } diff --git a/pkg/services/container.go b/pkg/services/container.go index cb98bf7..798cc63 100644 --- a/pkg/services/container.go +++ b/pkg/services/container.go @@ -5,14 +5,13 @@ import ( "database/sql" "fmt" "log/slog" + "os" + "strings" - "entgo.io/ent/dialect" entsql "entgo.io/ent/dialect/sql" - "entgo.io/ent/dialect/sql/schema" + _ "github.com/mattn/go-sqlite3" "github.com/mikestefanello/pagoda/pkg/funcmap" - // Required by ent - _ "github.com/jackc/pgx/v4/stdlib" "github.com/labstack/echo/v4" "github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/ent" @@ -71,20 +70,16 @@ func NewContainer() *Container { return c } -// Shutdown shuts the Container down and disconnects all connections +// Shutdown shuts the Container down and disconnects all connections. +// If the task runner was started, cancel the context to shut it down prior to calling this. func (c *Container) Shutdown() error { - if err := c.Tasks.Close(); err != nil { - return err - } - if err := c.Cache.Close(); err != nil { - return err - } if err := c.ORM.Close(); err != nil { return err } if err := c.Database.Close(); err != nil { return err } + c.Cache.Close() return nil } @@ -120,59 +115,41 @@ func (c *Container) initWeb() { // initCache initializes the cache func (c *Container) initCache() { - var err error - if c.Cache, err = NewCacheClient(c.Config); err != nil { + store, err := newInMemoryCache(c.Config.Cache.Capacity) + if err != nil { panic(err) } + + c.Cache = NewCacheClient(store) } // initDatabase initializes the database -// If the environment is set to test, the test database will be used and will be dropped, recreated and migrated func (c *Container) initDatabase() { var err error + var connection string - getAddr := func(dbName string) string { - return fmt.Sprintf("postgresql://%s:%s@%s:%d/%s", - c.Config.Database.User, - c.Config.Database.Password, - c.Config.Database.Hostname, - c.Config.Database.Port, - dbName, - ) + switch c.Config.App.Environment { + case config.EnvTest: + // TODO: Drop/recreate the DB, if this isn't in memory? + connection = c.Config.Database.TestConnection + default: + connection = c.Config.Database.Connection } - c.Database, err = sql.Open("pgx", getAddr(c.Config.Database.Database)) + c.Database, err = openDB(c.Config.Database.Driver, connection) if err != nil { - panic(fmt.Sprintf("failed to connect to database: %v", err)) - } - - // Check if this is a test environment - if c.Config.App.Environment == config.EnvTest { - // Drop the test database, ignoring errors in case it doesn't yet exist - _, _ = c.Database.Exec("DROP DATABASE " + c.Config.Database.TestDatabase) - - // Create the test database - if _, err = c.Database.Exec("CREATE DATABASE " + c.Config.Database.TestDatabase); err != nil { - panic(fmt.Sprintf("failed to create test database: %v", err)) - } - - // Connect to the test database - if err = c.Database.Close(); err != nil { - panic(fmt.Sprintf("failed to close database connection: %v", err)) - } - c.Database, err = sql.Open("pgx", getAddr(c.Config.Database.TestDatabase)) - if err != nil { - panic(fmt.Sprintf("failed to connect to database: %v", err)) - } + panic(err) } } // initORM initializes the ORM func (c *Container) initORM() { - drv := entsql.OpenDB(dialect.Postgres, c.Database) + drv := entsql.OpenDB(c.Config.Database.Driver, c.Database) c.ORM = ent.NewClient(ent.Driver(drv)) - if err := c.ORM.Schema.Create(context.Background(), schema.WithAtlas(true)); err != nil { - panic(fmt.Sprintf("failed to create database schema: %v", err)) + + // Run the auto migration tool. + if err := c.ORM.Schema.Create(context.Background()); err != nil { + panic(err) } } @@ -197,5 +174,30 @@ func (c *Container) initMail() { // initTasks initializes the task client func (c *Container) initTasks() { - c.Tasks = NewTaskClient(c.Config) + var err error + // You could use a separate database for tasks, if you'd like. but using one + // makes transaction support easier + c.Tasks, err = NewTaskClient(c.Config.Tasks, c.Database) + if err != nil { + panic(fmt.Sprintf("failed to create task client: %v", err)) + } +} + +// openDB opens a database connection +func openDB(driver, connection string) (*sql.DB, error) { + // Helper to automatically create the directories that the specified sqlite file + // should reside in, if one + if driver == "sqlite3" { + d := strings.Split(connection, "/") + + if len(d) > 1 { + path := strings.Join(d[:len(d)-1], "/") + + if err := os.MkdirAll(path, 0755); err != nil { + return nil, err + } + } + } + + return sql.Open(driver, connection) } diff --git a/pkg/services/tasks.go b/pkg/services/tasks.go index 8f4db4b..e399144 100644 --- a/pkg/services/tasks.go +++ b/pkg/services/tasks.go @@ -1,179 +1,204 @@ package services import ( - "encoding/json" - "fmt" + "bytes" + "context" + "database/sql" + "encoding/gob" + "strings" + "sync" "time" - "github.com/hibiken/asynq" + "github.com/maragudk/goqite" + "github.com/maragudk/goqite/jobs" "github.com/mikestefanello/pagoda/config" + "github.com/mikestefanello/pagoda/pkg/log" ) type ( - // TaskClient is that client that allows you to queue or schedule task execution + // TaskClient is that client that allows you to queue or schedule task execution. + // Under the hood we create only a single queue using goqite for all tasks because we do not want more than one + // runner to process the tasks. The TaskClient wrapper provides abstractions for separate, type-safe queues. TaskClient struct { - // client stores the asynq client - client *asynq.Client - - // scheduler stores the asynq scheduler - scheduler *asynq.Scheduler + queue *goqite.Queue + runner *jobs.Runner + buffers sync.Pool } - // task handles task creation operations - task struct { - client *TaskClient - typ string - payload any - periodic *string - queue *string - maxRetries *int - timeout *time.Duration - deadline *time.Time - at *time.Time - wait *time.Duration - retain *time.Duration + // Task is a job that can be added to a queue and later passed to and executed by a QueueSubscriber. + // See pkg/tasks for an example of how this can be used with a queue. + Task interface { + Name() string } + + // TaskSaveOp handles task save operations + TaskSaveOp struct { + client *TaskClient + task Task + tx *sql.Tx + at *time.Time + wait *time.Duration + } + + // Queue is a queue that a Task can be pushed to for execution. + // While this can be implemented directly, it's recommended to use NewQueue() which uses generics in + // order to provide type-safe queues and queue subscriber callbacks for task execution. + Queue interface { + // Name returns the name of the task this queue processes + Name() string + + // Receive receives the Task payload to be processed + Receive(ctx context.Context, payload []byte) error + } + + // queue provides a type-safe implementation of Queue + queue[T Task] struct { + name string + subscriber QueueSubscriber[T] + } + + // QueueSubscriber is a generic subscriber callback for a given queue to process Tasks + QueueSubscriber[T Task] func(context.Context, T) error ) // NewTaskClient creates a new task client -func NewTaskClient(cfg *config.Config) *TaskClient { - // Determine the database based on the environment - db := cfg.Cache.Database - if cfg.App.Environment == config.EnvTest { - db = cfg.Cache.TestDatabase +func NewTaskClient(cfg config.TasksConfig, db *sql.DB) (*TaskClient, error) { + // Install the schema + if err := goqite.Setup(context.Background(), db); err != nil { + // An error is returned if we already ran this and there's no better way to check. + // You can and probably should handle this via migrations + if !strings.Contains(err.Error(), "already exists") { + return nil, err + } } - conn := asynq.RedisClientOpt{ - Addr: fmt.Sprintf("%s:%d", cfg.Cache.Hostname, cfg.Cache.Port), - Password: cfg.Cache.Password, - DB: db, + t := &TaskClient{ + queue: goqite.New(goqite.NewOpts{ + DB: db, + Name: "tasks", + MaxReceive: cfg.MaxRetries, + }), + buffers: sync.Pool{ + New: func() interface{} { + return bytes.NewBuffer(nil) + }, + }, } - return &TaskClient{ - client: asynq.NewClient(conn), - scheduler: asynq.NewScheduler(conn, nil), - } + t.runner = jobs.NewRunner(jobs.NewRunnerOpts{ + Limit: cfg.Goroutines, + Log: log.Default(), + PollInterval: cfg.PollInterval, + Queue: t.queue, + }) + + return t, nil } -// Close closes the connection to the task service -func (t *TaskClient) Close() error { - return t.client.Close() +// StartRunner starts the scheduler service which adds scheduled tasks to the queue. +// This must be running in order to execute queued tasked. +// To stop the runner, cancel the context. +// This is a blocking call. +func (t *TaskClient) StartRunner(ctx context.Context) { + t.runner.Start(ctx) } -// StartScheduler starts the scheduler service which adds scheduled tasks to the queue -// This must be running in order to queue tasks set for periodic execution -func (t *TaskClient) StartScheduler() error { - return t.scheduler.Run() +// Register registers a queue so tasks can be added to it and processed +func (t *TaskClient) Register(queue Queue) { + t.runner.Register(queue.Name(), queue.Receive) } -// New starts a task creation operation -func (t *TaskClient) New(typ string) *task { - return &task{ +// New starts a task save operation +func (t *TaskClient) New(task Task) *TaskSaveOp { + return &TaskSaveOp{ client: t, - typ: typ, + task: task, } } -// Payload sets the task payload data which will be sent to the task handler -func (t *task) Payload(payload any) *task { - t.payload = payload - return t -} - -// Periodic sets the task to execute periodically according to a given interval -// The interval can be either in cron form ("*/5 * * * *") or "@every 30s" -func (t *task) Periodic(interval string) *task { - t.periodic = &interval - return t -} - -// Queue specifies the name of the queue to add the task to -// The default queue will be used if this is not set -func (t *task) Queue(queue string) *task { - t.queue = &queue - return t -} - -// Timeout sets the task timeout, meaning the task must execute within a given duration -func (t *task) Timeout(timeout time.Duration) *task { - t.timeout = &timeout - return t -} - -// Deadline sets the task execution deadline to a specific date and time -func (t *task) Deadline(deadline time.Time) *task { - t.deadline = &deadline - return t -} - // At sets the exact date and time the task should be executed -func (t *task) At(processAt time.Time) *task { - t.at = &processAt +func (t *TaskSaveOp) At(processAt time.Time) *TaskSaveOp { + t.Wait(time.Until(processAt)) return t } // Wait instructs the task to wait a given duration before it is executed -func (t *task) Wait(duration time.Duration) *task { +func (t *TaskSaveOp) Wait(duration time.Duration) *TaskSaveOp { t.wait = &duration return t } -// Retain instructs the task service to retain the task data for a given duration after execution is complete -func (t *task) Retain(duration time.Duration) *task { - t.retain = &duration +// Tx will include the task as part of a given database transaction +func (t *TaskSaveOp) Tx(tx *sql.Tx) *TaskSaveOp { + t.tx = tx return t } -// MaxRetries sets the maximum amount of times to retry executing the task in the event of a failure -func (t *task) MaxRetries(retries int) *task { - t.maxRetries = &retries - return t -} - -// Save saves the task so it can be executed -func (t *task) Save() error { - var err error - - // Build the payload - var payload []byte - if t.payload != nil { - if payload, err = json.Marshal(t.payload); err != nil { - return err - } +// Save saves the task, so it can be queued for execution +func (t *TaskSaveOp) Save() error { + type message struct { + Name string + Message []byte } - // Build the task options - opts := make([]asynq.Option, 0) - if t.queue != nil { - opts = append(opts, asynq.Queue(*t.queue)) + // Encode the task + taskBuf := t.client.buffers.Get().(*bytes.Buffer) + if err := gob.NewEncoder(taskBuf).Encode(t.task); err != nil { + return err } - if t.maxRetries != nil { - opts = append(opts, asynq.MaxRetry(*t.maxRetries)) + + // Wrap and encode the message + // This is needed as a workaround because goqite doesn't support delays using the jobs package, + // so we format the message the way it expects but use the queue to supply the delay + msgBuf := t.client.buffers.Get().(*bytes.Buffer) + wrapper := message{Name: t.task.Name(), Message: taskBuf.Bytes()} + if err := gob.NewEncoder(msgBuf).Encode(wrapper); err != nil { + return err } - if t.timeout != nil { - opts = append(opts, asynq.Timeout(*t.timeout)) - } - if t.deadline != nil { - opts = append(opts, asynq.Deadline(*t.deadline)) + + msg := goqite.Message{ + Body: msgBuf.Bytes(), } + if t.wait != nil { - opts = append(opts, asynq.ProcessIn(*t.wait)) - } - if t.retain != nil { - opts = append(opts, asynq.Retention(*t.retain)) - } - if t.at != nil { - opts = append(opts, asynq.ProcessAt(*t.at)) + msg.Delay = *t.wait } - // Build the task - task := asynq.NewTask(t.typ, payload, opts...) + // Put the buffers back in the pool for re-use + taskBuf.Reset() + msgBuf.Reset() + t.client.buffers.Put(taskBuf) + t.client.buffers.Put(msgBuf) - // Schedule, if needed - if t.periodic != nil { - _, err = t.client.scheduler.Register(*t.periodic, task) + if t.tx == nil { + return t.client.queue.Send(context.Background(), msg) } else { - _, err = t.client.client.Enqueue(task) + return t.client.queue.SendTx(context.Background(), t.tx, msg) } - return err +} + +// NewQueue queues a new type-safe Queue of a given Task type +func NewQueue[T Task](subscriber QueueSubscriber[T]) Queue { + var task T + + q := &queue[T]{ + name: task.Name(), + subscriber: subscriber, + } + + return q +} + +func (q *queue[T]) Name() string { + return q.name +} + +func (q *queue[T]) Receive(ctx context.Context, payload []byte) error { + var obj T + err := gob.NewDecoder(bytes.NewReader(payload)).Decode(&obj) + if err != nil { + return err + } + + return q.subscriber(ctx, obj) } diff --git a/pkg/services/tasks_test.go b/pkg/services/tasks_test.go index b76b843..a34385c 100644 --- a/pkg/services/tasks_test.go +++ b/pkg/services/tasks_test.go @@ -1,35 +1,69 @@ package services import ( + "context" + "database/sql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" "time" - - "github.com/stretchr/testify/assert" ) -func TestTaskClient_New(t *testing.T) { - now := time.Now() - tk := c.Tasks. - New("task1"). - Payload("payload"). - Queue("queue"). - Periodic("@every 5s"). - MaxRetries(5). - Timeout(5 * time.Second). - Deadline(now). - At(now). - Wait(6 * time.Second). - Retain(7 * time.Second) - - assert.Equal(t, "task1", tk.typ) - assert.Equal(t, "payload", tk.payload.(string)) - assert.Equal(t, "queue", *tk.queue) - assert.Equal(t, "@every 5s", *tk.periodic) - assert.Equal(t, 5, *tk.maxRetries) - assert.Equal(t, 5*time.Second, *tk.timeout) - assert.Equal(t, now, *tk.deadline) - assert.Equal(t, now, *tk.at) - assert.Equal(t, 6*time.Second, *tk.wait) - assert.Equal(t, 7*time.Second, *tk.retain) - assert.NoError(t, tk.Save()) +type testTask struct { + Val int +} + +func (t testTask) Name() string { + return "test_task" +} + +func TestTaskClient_New(t *testing.T) { + var subCalled bool + + queue := NewQueue[testTask](func(ctx context.Context, task testTask) error { + subCalled = true + assert.Equal(t, 123, task.Val) + return nil + }) + c.Tasks.Register(queue) + + task := testTask{Val: 123} + + tx := &sql.Tx{} + + op := c.Tasks. + New(task). + Wait(5 * time.Second). + Tx(tx) + + // Check that the task op was built correctly + assert.Equal(t, task, op.task) + assert.Equal(t, tx, op.tx) + assert.Equal(t, 5*time.Second, *op.wait) + + // Remove the transaction and delay so we can process the task immediately + op.tx, op.wait = nil, nil + err := op.Save() + require.NoError(t, err) + + // Start the runner + ctx, cancel := context.WithCancel(context.Background()) + go c.Tasks.StartRunner(ctx) + defer cancel() + + // Check for up to 5 seconds if the task executed + start := time.Now() +waitLoop: + for { + switch { + case subCalled: + break waitLoop + case time.Since(start) > (5 * time.Second): + break waitLoop + default: + time.Sleep(10 * time.Millisecond) + } + } + + assert.True(t, subCalled) } diff --git a/pkg/services/template_renderer.go b/pkg/services/template_renderer.go index 1d1abb7..92cb172 100644 --- a/pkg/services/template_renderer.go +++ b/pkg/services/template_renderer.go @@ -185,7 +185,7 @@ func (t *TemplateRenderer) cachePage(ctx echo.Context, page page.Page, html *byt // The request URL is used as the cache key so the middleware can serve the // cached page on matching requests key := ctx.Request().URL.String() - cp := CachedPage{ + cp := &CachedPage{ URL: key, HTML: html.Bytes(), Headers: headers, @@ -217,7 +217,6 @@ func (t *TemplateRenderer) GetCachedPage(ctx echo.Context, url string) (*CachedP Get(). Group(cachedPageGroup). Key(url). - Type(new(CachedPage)). Fetch(ctx.Request().Context()) if err != nil { diff --git a/pkg/tasks/example.go b/pkg/tasks/example.go index 653b676..3ef4b60 100644 --- a/pkg/tasks/example.go +++ b/pkg/tasks/example.go @@ -2,21 +2,35 @@ package tasks import ( "context" - "log" - "github.com/hibiken/asynq" + "github.com/mikestefanello/pagoda/pkg/log" + "github.com/mikestefanello/pagoda/pkg/services" ) -// TypeExample is the type for the example task. -// This is what is passed in to TaskClient.New() when creating a new task -const TypeExample = "example_task" - -// ExampleProcessor processes example tasks -type ExampleProcessor struct { +// ExampleTask is an example implementation of services.Task +// This represents the task that can be queued for execution via the task client and should contain everything +// that your queue subscriber needs to process the task. +type ExampleTask struct { + Message string } -// ProcessTask handles the processing of the task -func (p *ExampleProcessor) ProcessTask(ctx context.Context, t *asynq.Task) error { - log.Printf("executing task: %s", t.Type()) - return nil +// Name satisfies the services.Task interface by proviing a unique name for this Task type +func (t ExampleTask) Name() string { + return "example_task" +} + +// NewExampleTaskQueue provides a Queue that can process ExampleTask tasks +// The service container is provided so the subscriber can have access to the app dependencies. +// All queues must be registered in the Register() function. +// Whenever an ExampleTask is added to the task client, it will be queued and eventually sent here for execution. +func NewExampleTaskQueue(c *services.Container) services.Queue { + return services.NewQueue[ExampleTask](func(ctx context.Context, task ExampleTask) error { + log.Default().Info("Example task received", + "message", task.Message, + ) + log.Default().Info("This can access the container for dependencies", + "echo", c.Web.Reverse("home"), + ) + return nil + }) } diff --git a/pkg/tasks/register.go b/pkg/tasks/register.go new file mode 100644 index 0000000..895b974 --- /dev/null +++ b/pkg/tasks/register.go @@ -0,0 +1,10 @@ +package tasks + +import ( + "github.com/mikestefanello/pagoda/pkg/services" +) + +// Register registers all task queues with the task client +func Register(c *services.Container) { + c.Tasks.Register(NewExampleTaskQueue(c)) +} diff --git a/templates/layouts/main.gohtml b/templates/layouts/main.gohtml index e256c9c..66f872b 100644 --- a/templates/layouts/main.gohtml +++ b/templates/layouts/main.gohtml @@ -28,6 +28,8 @@
  • {{link (url "home") "Dashboard" .Path}}
  • {{link (url "about") "About" .Path}}
  • {{link (url "contact") "Contact" .Path}}
  • +
  • {{link (url "cache") "Cache" .Path}}
  • +
  • {{link (url "task") "Task" .Path}}
  • diff --git a/templates/pages/about.gohtml b/templates/pages/about.gohtml index 06524c2..2905bb0 100644 --- a/templates/pages/about.gohtml +++ b/templates/pages/about.gohtml @@ -19,7 +19,7 @@

    Warning

    - This route has caching enabled so hot-reloading in the local environment will not work. Check the Redis cache for a key matching the URL path. + This route has caching enabled so hot-reloading in the local environment will not work.
    {{- end}} diff --git a/templates/pages/cache.gohtml b/templates/pages/cache.gohtml new file mode 100644 index 0000000..a0bb0af --- /dev/null +++ b/templates/pages/cache.gohtml @@ -0,0 +1,36 @@ +{{define "content"}} +
    +
    +
    +

    Test the cache

    +
    +
    + This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page. + HTMX makes it easy to re-render the cached value after the form is submitted. +
    +
    + + + {{if .Data}} + {{.Data}} + {{- else}} + (empty) + {{- end}} +

    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    + + {{template "csrf" .}} +
    +{{end}} \ No newline at end of file diff --git a/templates/pages/task.gohtml b/templates/pages/task.gohtml new file mode 100644 index 0000000..6436c48 --- /dev/null +++ b/templates/pages/task.gohtml @@ -0,0 +1,43 @@ +{{define "content"}} + {{- if not (eq .HTMX.Request.Target "task")}} + + {{- end}} + + {{template "form" .}} +{{end}} + +{{define "form"}} +
    + {{template "messages" .}} +
    + +
    + +
    +

    How long to wait until the task is executed

    + {{template "field-errors" (.Form.GetFieldErrors "Delay")}} +
    + +
    + +
    + +
    +

    The message the task will output to the log

    + {{template "field-errors" (.Form.GetFieldErrors "Message")}} +
    + +
    +
    + +
    +
    + + {{template "csrf" .}} +
    +{{end}} \ No newline at end of file diff --git a/templates/templates.go b/templates/templates.go index bb858bb..1986422 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -22,6 +22,7 @@ const ( const ( PageAbout Page = "about" + PageCache Page = "cache" PageContact Page = "contact" PageError Page = "error" PageForgotPassword Page = "forgot-password" @@ -30,6 +31,7 @@ const ( PageRegister Page = "register" PageResetPassword Page = "reset-password" PageSearch Page = "search" + PageTask Page = "task" ) //go:embed * @@ -41,7 +43,7 @@ func Get() embed.FS { } // GetOS returns a file system containing all templates which will load the files directly from the operating system. -// This should only be used for local development in order to faciliate live reloading. +// This should only be used for local development in order to facilitate live reloading. func GetOS() fs.FS { // Gets the complete templates directory path // This is needed in case this is called from a package outside of main, such as within tests