├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── client.go ├── client_test.go ├── dispatcher.go ├── dispatcher_test.go ├── go.mod ├── go.sum ├── internal ├── query │ ├── query.go │ ├── query_test.go │ └── schema.sql ├── task │ ├── completed.go │ ├── task.go │ └── tasks.go └── testutil │ ├── testutil.go │ └── testutil_test.go ├── logger.go ├── queue.go ├── queue_test.go ├── task.go ├── task_test.go ├── task_types_test.go └── ui ├── README.md ├── handler.go ├── handler_test.go ├── query.go ├── setup_test.go ├── templates.go ├── templates ├── completed_task.gohtml ├── completed_tasks.gohtml ├── layout.gohtml ├── running.gohtml ├── task.gohtml └── upcoming.gohtml └── templates_test.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: 1.23 20 | 21 | - uses: actions/cache@v3 22 | with: 23 | path: | 24 | ~/.cache/go-build 25 | ~/go/pkg/mod 26 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 27 | restore-keys: | 28 | ${{ runner.os }}-go- 29 | 30 | - name: Test 31 | run: go test ./... 32 | 33 | - name: Update coverage report 34 | uses: ncruces/go-coverage-report@v0 35 | with: 36 | report: true 37 | chart: true 38 | amend: true 39 | if: | 40 | github.event_name == 'push' 41 | continue-on-error: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.db* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mike Stefanello 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Backlite: Type-safe, persistent, embedded task queues and background job runner w/ SQLite 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/mikestefanello/backlite)](https://goreportcard.com/report/github.com/mikestefanello/backlite) 4 | [![Test](https://github.com/mikestefanello/backlite/actions/workflows/test.yml/badge.svg)](https://github.com/mikestefanello/backlite/actions/workflows/test.yml) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | [![Go Reference](https://pkg.go.dev/badge/github.com/mikestefanello/backlite.svg)](https://pkg.go.dev/github.com/mikestefanello/backlite) 7 | [![GoT](https://img.shields.io/badge/Made%20with-Go-1f425f.svg)](https://go.dev) 8 | [![Test coverage](https://raw.githubusercontent.com/wiki/mikestefanello/backlite/coverage.svg)](https://raw.githack.com/wiki/mikestefanello/backlite/coverage.html) 9 | 10 |

Logo

11 | 12 | ## Table of Contents 13 | * [Introduction](#introduction) 14 | * [Overview](#overview) 15 | * [Origin](#origin) 16 | * [Screenshots](#screenshots) 17 | * [Status](#status) 18 | * [Installation](#installation) 19 | * [Features](#features) 20 | * [Type-safety](#type-safety) 21 | * [Persistence with SQLite](#persistence-with-sqlite) 22 | * [Optional retention](#optional-retention) 23 | * [Retry & Backoff](#retry--backoff) 24 | * [Scheduled execution](#scheduled-execution) 25 | * [Logging](#logging) 26 | * [Nested tasks](#nested-tasks) 27 | * [Graceful shutdown](#graceful-shutdown) 28 | * [Transactions](#transactions) 29 | * [No database polling](#no-database-polling) 30 | * [Driver flexibility](#driver-flexibility) 31 | * [Bulk inserts](#bulk-inserts) 32 | * [Execution timeout](#execution-timeout) 33 | * [Background worker pool](#background-worker-pool) 34 | * [Web UI](#web-ui) 35 | * [Panic recovery](#panic-recovery) 36 | * [Usage](#usage) 37 | * [Client initialization](#client-initialization) 38 | * [Schema installation](#schema-installation) 39 | * [Declaring a Task type](#declaring-a-task-type) 40 | * [Queue processor](#queue-processor) 41 | * [Registering a queue](#registering-a-queue) 42 | * [Adding tasks](#adding-tasks) 43 | * [Starting the dispatcher](#starting-the-dispatcher) 44 | * [Shutting down the dispatcher](#shutting-down-the-dispatcher) 45 | * [Example](#example) 46 | * [Roadmap](#roadmap) 47 | 48 | ## Introduction 49 | 50 | ### Overview 51 | 52 | Backlite provides type-safe, persistent and embedded task queues meant to run within your application as opposed to an external message broker. A task can be of any type and each type declares its own queue along with the configuration for the queue. Tasks are automatically executed in the background via a configurable worker pool. 53 | 54 | ### Origin 55 | 56 | This project started shortly after migrating [Pagoda](https://github.com/mikestefanello/pagoda) to SQLite from Postgres and Redis. Redis was previously used to handle task queues and I wanted to leverage SQLite instead. Originally, [goqite](https://github.com/maragudk/goqite) was chosen, which is a great library and one that I took inspiration from, but I had a lot of different ideas for the overall approach and it lacked a number of features that I felt were needed. 57 | 58 | [River](https://github.com/riverqueue/river), an excellent, similar library built for Postgres, was also a major source of inspiration and ideas. 59 | 60 | ### Screenshots 61 | 62 | Failed tasks 63 | 64 | Task details 65 | 66 | Task failed 67 | 68 | ### Status 69 | 70 | This project is under active development, though all features outlined below are available and complete. No significant API or schema changes are expected at this time, but it is certainly possible. 71 | 72 | ## Installation 73 | 74 | Install by simply running: `go get github.com/mikestefanello/backlite` 75 | 76 | ## Features 77 | 78 | ### Type-safety 79 | 80 | No need to deal with serialization and byte slices. By leveraging generics, tasks and queues are completely type-safe which means that you pass in your task type into a queue and your task processor callbacks will only receive that given type. 81 | 82 | ### Persistence with SQLite 83 | 84 | When tasks are added to a queue, they are inserted into a SQLite database table to ensure persistence. 85 | 86 | ### Optional retention 87 | 88 | Each queue can have completed tasks retained in a separate table for archiving, auditing, monitoring, etc. Options exist to retain all completed tasks or only those that failed all attempts. An option also exists to retain the task data for all tasks or only those that failed. 89 | 90 | ### Retry & Backoff 91 | 92 | Each queue can be configured to retry tasks a certain number of times and to backoff a given amount of time between each attempt. 93 | 94 | ### Scheduled execution 95 | 96 | When adding a task to a queue, you can specify a duration or specific time to wait until executing the task. 97 | 98 | ### Logging 99 | 100 | Optionally log queue operations with a logger of your choice, as long as it implements the simple `Logger` interface, which `log/slog` does. 101 | 102 | ``` 103 | 2024/07/21 14:08:13 INFO task processed id=0190d67a-d8da-76d4-8fb8-ded870d69151 queue=example duration=85.101µs attempt=1 104 | ``` 105 | 106 | ### Nested tasks 107 | 108 | While processing a given task, it's easy to create another task in the same or a different queue, which allows you to nest tasks to create workflows. Use `FromContext()` with the provided context to get your initialized client from within the task processor, and add one or many tasks. 109 | 110 | ### Graceful shutdown 111 | 112 | The task dispatcher, which handles sending tasks to the worker pool for execution, can be shutdown gracefully by calling `Stop()` on the client. That will wait for all workers to finish for as long as the passed in context is not cancelled. The hard-stop the dispatcher, cancel the context passed in when calling `Start()`. See usage below. 113 | 114 | ### Transactions 115 | 116 | Task creation can be added to a given database transaction. If you are using SQLite as your primary database, this provides a simple, robust way to ensure data integrity. For example, using the eCommerce app example, when inserting a new order into your database, the same transaction to be used to add a task to send an order notification email, and they either both succeed or both fail. Use the chained method `Tx()` to provide your transaction when adding one or multiple tasks. 117 | 118 | ### No database polling 119 | 120 | Since SQLite only supports one writer, no continuous database polling is required. The task dispatcher is able to remain aware of new tasks and keep track of when future tasks are scheduled for, and thus only queries the database when it needs to. 121 | 122 | ### Driver flexibility 123 | 124 | Use any SQLite driver that you'd like. This library only includes [go-sqlite3](https://github.com/mattn/go-sqlite3) since it is used in tests. 125 | 126 | ### Bulk inserts 127 | 128 | Insert one or many tasks across one or many queues in a single operation. 129 | 130 | ### Execution timeout 131 | 132 | Each queue can be configured with an execution timeout for processing a given task. The provided context will cancel after the time elapses. If you want to respect that timeout, your processor code will have to listen for the context cancellation. 133 | 134 | ### Background worker pool 135 | 136 | When creating a client, you can specify the amount of goroutines to use to build a worker pool. This pool is created and shutdown via the dispatcher by calling `Start()` and `Stop()` on the client. The worker pool is the only way to process tasks; they cannot be pulled manually. 137 | 138 | ### Web UI 139 | 140 | A simple web UI to monitor running, upcoming, and completed tasks is provided. 141 | 142 | To run, pass your `*sql.DB` to `ui.NewHandler()` and register that to an HTTP handler, for example: 143 | 144 | ```go 145 | mux := http.DefaultServeMux 146 | h, err := ui.NewHandler(ui.Config{ 147 | DB: db, 148 | }) 149 | h.Register(mux) 150 | err := http.ListenAndServe(":9000", mux) 151 | ``` 152 | 153 | Then visit the given port and/or domain in your browser (ie, `localhost:9000`). 154 | 155 | The web CSS is provided by [tabler](https://github.com/tabler/tabler). 156 | 157 | ### Panic recovery 158 | 159 | If any of your task processors panics, the application with automatically recover, mark the task as failed, and store the panic message as the error message. 160 | 161 | ## Usage 162 | 163 | ### Client initialization 164 | 165 | First, open a connection to your SQLite database using the driver of your choice: 166 | 167 | ```go 168 | db, err := sql.Open("sqlite3", "data.db?_journal=WAL&_timeout=5000") 169 | ``` 170 | 171 | Second, initialize a client: 172 | 173 | ```go 174 | client, err := backlite.NewClient(backlite.ClientConfig{ 175 | DB: db, 176 | Logger: slog.Default(), 177 | ReleaseAfter: 10 * time.Minute, 178 | NumWorkers: 10, 179 | CleanupInterval: time.Hour, 180 | }) 181 | ``` 182 | 183 | The configuration options are: 184 | 185 | * **DB**: The database connection. 186 | * **Logger**: A logger that implements the `Logger` interface. Omit if you do not want to log. 187 | * **ReleaseAfter**: The duration after which tasks claimed and passed for execution should be added back to the queue if a response was never received. 188 | * **NumWorkers**: The amount of goroutines to open which will process queued tasks. 189 | * **CleanupInterval**: How often the completed tasks database table will attempt to remove expired rows. 190 | 191 | ### Schema installation 192 | 193 | Until a more robust system is provided, to install the database schema, call `client.Install()`. This must be done prior to using the client. It is safe to call this if the schema was previously installed. The schema is currently defined in `internal/query/schema.sql`. 194 | 195 | ### Declaring a Task type 196 | 197 | Any type can be a task as long as it implements the `Task` interface, which requires only the `Config() QueueConfig` method, used to provide information about the queue that these tasks will be added to. All fields should be exported. As an example, this is a task used to send new order email notifications: 198 | 199 | ```go 200 | type NewOrderEmailTask struct { 201 | OrderID string 202 | EmailAddress string 203 | } 204 | ``` 205 | 206 | Then implement the `Task` interface by providing the queue configuration: 207 | 208 | ```go 209 | func (t NewOrderEmailTask) Config() backlite.QueueConfig { 210 | return backlite.QueueConfig{ 211 | Name: "NewOrderEmail", 212 | MaxAttempts: 5, 213 | Backoff: 5 * time.Second, 214 | Timeout: 10 * time.Second, 215 | Retention: &backlite.Retention{ 216 | Duration: 6 * time.Hour, 217 | OnlyFailed: false, 218 | Data: &backlite.RetainData{ 219 | OnlyFailed: true, 220 | }, 221 | }, 222 | } 223 | } 224 | ``` 225 | 226 | The configuration options are: 227 | 228 | * **Name**: The name of the queue. This must be unique otherwise registering the queue will fail. 229 | * **MaxAttempts**: The maximum number of times to try executing this task before it's consider failed and marked as complete. 230 | * **Backoff**: The amount of time to wait before retrying after a failed attempt at processing. 231 | * **Retention**: If provided, completed tasks will be retained in the database in a separate table according to the included options. 232 | * **Duration**: How long to retain completed tasks in the database for. Omit to never expire. 233 | * **OnlyFailed**: If true, only failed tasks will be retained. 234 | * **Data**: If provided, the task data (the serialized task itself) will be retained. 235 | * **OnlyFailed**: If true, the task data will only be retained for failed tasks. 236 | 237 | ### Queue processor 238 | 239 | The easiest way to implement a queue and define the processor is to use `backlite.NewQueue()`. This leverages generics in order to provide type-safety with a given task type. Using the example above: 240 | 241 | ```go 242 | processor := func(ctx context.Context, task NewOrderEmailTask) error { 243 | return email.Send(ctx, task.EmailAddress, fmt.Sprintf("Order %s received", task.OrderID)) 244 | } 245 | 246 | queue := backlite.NewQueue[NewOrderEmailTask](processor) 247 | ``` 248 | 249 | The parameter is the processor callback which is what will be called by the dispatcher worker pool to execute the task. If no error is returned, the task is considered successfully executed. If the task fails all attempts and the queue has retention enabled, the value of the error will be stored in the database. 250 | 251 | The provided context will be set to timeout at the duration set in the queue settings, if provided. To get the client from the context, you can call `client := backlite.FromContext(ctx)`. 252 | 253 | ### Registering a queue 254 | 255 | You must register all queues with the client by calling `client.Register(queue)`. This will panic if duplicate queue names are registered. 256 | 257 | ### Adding tasks 258 | 259 | To add a task to the queue, simply pass one or many into `client.Add()`. You can provide tasks of different types. This returns a chainable operation which contains many options, that can be used as follows: 260 | 261 | ```go 262 | err := client. 263 | Add(task1, task2). 264 | Ctx(ctx). 265 | Tx(tx). 266 | At(time.Date(2024, 1, 5, 12, 30, 00)). 267 | Wait(15 * time.Minute). 268 | Save() 269 | ``` 270 | 271 | Only `Add()` and `Save()` are required. Don't use `At()` and `Wait()` together as they override each other. 272 | 273 | The options are: 274 | 275 | * **Ctx**: Provide a context to use for the operation. 276 | * **Tx**: Provide a database transaction to add the tasks to. You must commit this yourself then call `client.Notify()` to tell the dispatcher that the new task(s) were added. This may be improved in the future but for now it is required. 277 | * **At**: Don't execute this task until at least the given date and time. 278 | * **Wait**: Wait at least the given duration before executing the task. 279 | 280 | ### Starting the dispatcher 281 | 282 | To start the dispatcher, which will spin up the worker pool and begin executing tasks in the background, call `client.Start()`. The context you pass in must persist for as long as you want the dispatcher to continue working. If that is ever cancelled, the dispatcher will shutdown. See the next section for more details. 283 | 284 | ### Shutting down the dispatcher 285 | 286 | To gracefully shutdown the dispatcher, which will wait until all tasks currently being executed are finished, call `client.Stop()`. You can provide a context with a given timeout in order to give the shutdown process a set amount of time to gracefully shutdown. For example: 287 | 288 | ```go 289 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 290 | defer cancel() 291 | client.Stop(ctx) 292 | ``` 293 | 294 | This will wait up to 5 seconds for all workers to complete the task they are currently working on. 295 | 296 | If you want to hard-stop the dispatcher, cancel the context that was provided when calling `client.Start()`. 297 | 298 | ### Example 299 | 300 | To see a working example, check out the example provided in [Pagoda](https://github.com/mikestefanello/pagoda/?tab=readme-ov-file#queues). When the app starts, a queue is defined and the dispatcher is started. There is a web route that includes a form which creates a task in the queue when it is submitted. 301 | 302 | ## Roadmap 303 | 304 | - Expand testing 305 | - Hooks 306 | - Expand processor context to store attempt number, other data 307 | - Avoid needing to call Notify() when using transaction 308 | - Queue priority 309 | - Better handling of database schema, migrations 310 | - Store queue stats in a separate table? 311 | - Pause/resume queues 312 | - Benchmarks -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "database/sql" 7 | "encoding/json" 8 | "errors" 9 | "sync" 10 | "time" 11 | 12 | "github.com/mikestefanello/backlite/internal/query" 13 | "github.com/mikestefanello/backlite/internal/task" 14 | ) 15 | 16 | // now returns the current time in a way that tests can override. 17 | var now = func() time.Time { return time.Now() } 18 | 19 | type ( 20 | // Client is a client used to register queues and add tasks to them for execution. 21 | Client struct { 22 | // db stores the database to use for storing tasks. 23 | db *sql.DB 24 | 25 | // log is the logger. 26 | log Logger 27 | 28 | // queues stores the registered queues which tasks can be added to. 29 | queues queues 30 | 31 | // buffers is a pool of byte buffers for more efficient encoding. 32 | buffers sync.Pool 33 | 34 | // dispatcher is used to fetch and dispatch queued tasks to the workers for execution. 35 | dispatcher Dispatcher 36 | } 37 | 38 | // ClientConfig contains configuration for the Client. 39 | ClientConfig struct { 40 | // DB is the open database connection used for storing tasks. 41 | DB *sql.DB 42 | 43 | // Logger is the logger used to log task execution. 44 | Logger Logger 45 | 46 | // NumWorkers is the number of goroutines to open to use for executing queued tasks concurrently. 47 | NumWorkers int 48 | 49 | // ReleaseAfter is the duration after which a task is released back to a queue if it has not finished executing. 50 | // This value should be much higher than the timeout setting used for each queue and exists as a fail-safe 51 | // just in case tasks become stuck. 52 | ReleaseAfter time.Duration 53 | 54 | // CleanupInterval is how often to run cleanup operations on the database in order to remove expired completed 55 | // tasks. If omitted, no cleanup operations will be performed and the task retention duration will be ignored. 56 | CleanupInterval time.Duration 57 | } 58 | 59 | // ctxKeyClient is used to store a Client in a context. 60 | ctxKeyClient struct{} 61 | ) 62 | 63 | // FromContext returns a Client from a context which is set for queue processor callbacks, so they can access 64 | // the client in order to create additional tasks. 65 | func FromContext(ctx context.Context) *Client { 66 | if c, ok := ctx.Value(ctxKeyClient{}).(*Client); ok { 67 | return c 68 | } 69 | return nil 70 | } 71 | 72 | // NewClient initializes a new Client 73 | func NewClient(cfg ClientConfig) (*Client, error) { 74 | switch { 75 | case cfg.DB == nil: 76 | return nil, errors.New("missing database") 77 | 78 | case cfg.NumWorkers < 1: 79 | return nil, errors.New("at least one worker required") 80 | 81 | case cfg.ReleaseAfter <= 0: 82 | return nil, errors.New("release duration must be greater than zero") 83 | } 84 | 85 | if cfg.Logger == nil { 86 | cfg.Logger = &noLogger{} 87 | } 88 | 89 | c := &Client{ 90 | db: cfg.DB, 91 | log: cfg.Logger, 92 | queues: queues{registry: make(map[string]Queue)}, 93 | buffers: sync.Pool{ 94 | New: func() any { 95 | return bytes.NewBuffer(nil) 96 | }, 97 | }, 98 | } 99 | 100 | c.dispatcher = &dispatcher{ 101 | client: c, 102 | log: cfg.Logger, 103 | numWorkers: cfg.NumWorkers, 104 | releaseAfter: cfg.ReleaseAfter, 105 | cleanupInterval: cfg.CleanupInterval, 106 | } 107 | 108 | return c, nil 109 | } 110 | 111 | // Register registers a new Queue so tasks can be added to it. 112 | // This will panic if the name of the queue provided has already been registered. 113 | func (c *Client) Register(queue Queue) { 114 | c.queues.add(queue) 115 | } 116 | 117 | // Add starts an operation to add one or many tasks. 118 | func (c *Client) Add(tasks ...Task) *TaskAddOp { 119 | return &TaskAddOp{ 120 | client: c, 121 | tasks: tasks, 122 | } 123 | } 124 | 125 | // Start starts the dispatcher so queued tasks can automatically be executed in the background. 126 | // To gracefully shut down the dispatcher, call Stop(), or to hard-stop, cancel the provided context. 127 | func (c *Client) Start(ctx context.Context) { 128 | c.dispatcher.Start(ctx) 129 | } 130 | 131 | // Stop attempts to gracefully shut down the dispatcher before the provided context is cancelled. 132 | // True is returned if all workers were able to complete their tasks prior to shutting down. 133 | func (c *Client) Stop(ctx context.Context) bool { 134 | return c.dispatcher.Stop(ctx) 135 | } 136 | 137 | // Install installs the provided schema in the database. 138 | // TODO provide migrations 139 | func (c *Client) Install() error { 140 | _, err := c.db.Exec(query.Schema) 141 | return err 142 | } 143 | 144 | // Notify notifies the dispatcher that a new task has been added. 145 | // This is only needed and required if you supply a database transaction when adding a task. 146 | // See TaskAddOp.Tx(). 147 | func (c *Client) Notify() { 148 | c.dispatcher.Notify() 149 | } 150 | 151 | // save saves a task add operation. 152 | func (c *Client) save(op *TaskAddOp) error { 153 | var commit bool 154 | var err error 155 | 156 | // Get a buffer for the encoding. 157 | buf := c.buffers.Get().(*bytes.Buffer) 158 | 159 | // Put the buffer back in the pool for re-use. 160 | defer func() { 161 | buf.Reset() 162 | c.buffers.Put(buf) 163 | }() 164 | 165 | if op.ctx == nil { 166 | op.ctx = context.Background() 167 | } 168 | 169 | // Start a transaction if one isn't provided. 170 | if op.tx == nil { 171 | op.tx, err = c.db.BeginTx(op.ctx, nil) 172 | if err != nil { 173 | return err 174 | } 175 | commit = true 176 | 177 | defer func() { 178 | if err == nil { 179 | return 180 | } 181 | 182 | if err = op.tx.Rollback(); err != nil { 183 | c.log.Error("failed to rollback task creation transaction", 184 | "error", err, 185 | ) 186 | } 187 | }() 188 | } 189 | 190 | // Insert the tasks. 191 | for _, t := range op.tasks { 192 | buf.Reset() 193 | 194 | if err = json.NewEncoder(buf).Encode(t); err != nil { 195 | return err 196 | } 197 | 198 | m := task.Task{ 199 | Queue: t.Config().Name, 200 | Task: buf.Bytes(), 201 | WaitUntil: op.wait, 202 | CreatedAt: now(), 203 | } 204 | 205 | if err = m.InsertTx(op.ctx, op.tx); err != nil { 206 | return err 207 | } 208 | } 209 | 210 | // If we created the transaction we'll commit it now. 211 | if commit { 212 | if err = op.tx.Commit(); err != nil { 213 | return err 214 | } 215 | 216 | // Tell the dispatcher that a new task has been added. 217 | c.Notify() 218 | } 219 | 220 | return nil 221 | } 222 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mikestefanello/backlite/internal/testutil" 11 | ) 12 | 13 | type mockDispatcher struct { 14 | started bool 15 | stopped bool 16 | gracefulStop bool 17 | notified bool 18 | } 19 | 20 | func (d *mockDispatcher) Start(_ context.Context) { 21 | d.started = true 22 | } 23 | 24 | func (d *mockDispatcher) Stop(_ context.Context) bool { 25 | d.stopped = true 26 | return d.gracefulStop 27 | } 28 | 29 | func (d *mockDispatcher) Notify() { 30 | d.notified = true 31 | } 32 | 33 | func TestMain(m *testing.M) { 34 | n := time.Now().Round(time.Millisecond) 35 | now = func() time.Time { 36 | return n 37 | } 38 | 39 | os.Exit(m.Run()) 40 | } 41 | 42 | func TestNewClient(t *testing.T) { 43 | db := testutil.NewDB(t) 44 | defer db.Close() 45 | 46 | c, err := NewClient(ClientConfig{ 47 | DB: db, 48 | Logger: slog.Default(), 49 | NumWorkers: 2, 50 | ReleaseAfter: time.Second, 51 | CleanupInterval: time.Hour, 52 | }) 53 | 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | if c.dispatcher == nil { 59 | t.Fatal("dispatcher is nil") 60 | } 61 | 62 | d, ok := c.dispatcher.(*dispatcher) 63 | if !ok { 64 | t.Fatal("dispatcher not set") 65 | } 66 | 67 | if c.log != slog.Default() { 68 | t.Error("log wrong value") 69 | } 70 | 71 | testutil.Equal(t, "client", c, d.client) 72 | testutil.Equal(t, "db", db, c.db) 73 | testutil.Equal(t, "log", d.log, c.log) 74 | testutil.Equal(t, "workers", 2, d.numWorkers) 75 | testutil.Equal(t, "release after", time.Second, d.releaseAfter) 76 | testutil.Equal(t, "cleanup interval", time.Hour, d.cleanupInterval) 77 | } 78 | 79 | func TestNewClient__DefaultLogger(t *testing.T) { 80 | c := mustNewClient(t) 81 | 82 | if c.log == nil { 83 | t.Fatal("log is nil") 84 | } 85 | 86 | _, ok := c.log.(*noLogger) 87 | if !ok { 88 | t.Error("log not set to noLogger") 89 | } 90 | } 91 | 92 | func TestNewClient__Validation(t *testing.T) { 93 | db := testutil.NewDB(t) 94 | defer db.Close() 95 | 96 | _, err := NewClient(ClientConfig{ 97 | DB: nil, 98 | NumWorkers: 1, 99 | ReleaseAfter: time.Second, 100 | }) 101 | if err == nil { 102 | t.Error("expected error, got none") 103 | } 104 | 105 | _, err = NewClient(ClientConfig{ 106 | DB: db, 107 | NumWorkers: 0, 108 | ReleaseAfter: time.Second, 109 | }) 110 | if err == nil { 111 | t.Error("expected error, got none") 112 | } 113 | 114 | _, err = NewClient(ClientConfig{ 115 | DB: db, 116 | NumWorkers: 1, 117 | ReleaseAfter: time.Duration(0), 118 | }) 119 | if err == nil { 120 | t.Error("expected error, got none") 121 | } 122 | } 123 | 124 | func TestClient_Register(t *testing.T) { 125 | c := mustNewClient(t) 126 | 127 | q := NewQueue[testTask](func(_ context.Context, _ testTask) error { 128 | return nil 129 | }) 130 | c.Register(q) 131 | 132 | var panicked bool 133 | func() { 134 | defer func() { 135 | if r := recover(); r != nil { 136 | panicked = true 137 | } 138 | }() 139 | c.Register(q) 140 | }() 141 | 142 | if !panicked { 143 | t.Error("expected panic") 144 | } 145 | 146 | q = NewQueue[testTaskNoName](func(_ context.Context, _ testTaskNoName) error { 147 | return nil 148 | }) 149 | panicked = false 150 | func() { 151 | defer func() { 152 | if r := recover(); r != nil { 153 | panicked = true 154 | } 155 | }() 156 | c.Register(q) 157 | }() 158 | 159 | if !panicked { 160 | t.Error("expected panic") 161 | } 162 | } 163 | 164 | func TestClient_Install(t *testing.T) { 165 | c := mustNewClient(t) 166 | 167 | err := c.Install() 168 | if err != nil { 169 | t.Fatal(err) 170 | } 171 | 172 | _, err = c.db.Exec("SELECT 1 FROM backlite_tasks") 173 | if err != nil { 174 | t.Error("table backlite_tasks not created") 175 | } 176 | 177 | _, err = c.db.Exec("SELECT 1 FROM backlite_tasks_completed") 178 | if err != nil { 179 | t.Error("table backlite_tasks_completed not created") 180 | } 181 | } 182 | 183 | func TestClient_Add(t *testing.T) { 184 | c := mustNewClient(t) 185 | 186 | t1, t2 := testTask{}, testTask{} 187 | op := c.Add(t1, t2) 188 | 189 | if op.client != c { 190 | t.Error("client not set") 191 | } 192 | 193 | if len(op.tasks) != 2 { 194 | t.Error("tasks not set") 195 | } else { 196 | if op.tasks[0] != t1 || op.tasks[1] != t2 { 197 | t.Error("tasks do not match") 198 | } 199 | } 200 | } 201 | 202 | func TestClient_Start(t *testing.T) { 203 | c := mustNewClient(t) 204 | m := &mockDispatcher{} 205 | c.dispatcher = m 206 | 207 | c.Start(context.Background()) 208 | testutil.Equal(t, "started", true, m.started) 209 | } 210 | 211 | func TestClient_Stop(t *testing.T) { 212 | c := mustNewClient(t) 213 | m := &mockDispatcher{} 214 | c.dispatcher = m 215 | 216 | c.Stop(context.Background()) 217 | testutil.Equal(t, "stopped", true, m.stopped) 218 | testutil.Equal(t, "graceful", false, m.gracefulStop) 219 | 220 | m.stopped = false 221 | m.gracefulStop = true 222 | c.Stop(context.Background()) 223 | testutil.Equal(t, "stopped", true, m.stopped) 224 | testutil.Equal(t, "graceful", true, m.gracefulStop) 225 | } 226 | 227 | func TestClient_Notify(t *testing.T) { 228 | c := mustNewClient(t) 229 | m := &mockDispatcher{} 230 | c.dispatcher = m 231 | 232 | c.Notify() 233 | testutil.Equal(t, "notified", true, m.notified) 234 | } 235 | 236 | func TestClient_FromContext(t *testing.T) { 237 | got := FromContext(context.Background()) 238 | testutil.Equal(t, "client", got, nil) 239 | 240 | c := &Client{} 241 | ctx := context.WithValue(context.Background(), ctxKeyClient{}, c) 242 | got = FromContext(ctx) 243 | testutil.Equal(t, "client", got, c) 244 | } 245 | 246 | func mustNewClient(t *testing.T) *Client { 247 | client, err := NewClient(ClientConfig{ 248 | DB: testutil.NewDB(t), 249 | NumWorkers: 1, 250 | ReleaseAfter: time.Hour, 251 | CleanupInterval: 6 * time.Hour, 252 | }) 253 | if err != nil { 254 | t.Fatal(err) 255 | } 256 | 257 | return client 258 | } 259 | -------------------------------------------------------------------------------- /dispatcher.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "sync/atomic" 8 | "time" 9 | 10 | "github.com/mikestefanello/backlite/internal/task" 11 | ) 12 | 13 | type ( 14 | // Dispatcher handles automatically pulling queued tasks and executing them via queue processors. 15 | Dispatcher interface { 16 | // Start starts the dispatcher. 17 | Start(context.Context) 18 | 19 | // Stop stops the dispatcher. 20 | Stop(context.Context) bool 21 | 22 | // Notify notifies the dispatcher that a new task has been added. 23 | Notify() 24 | } 25 | 26 | // dispatcher implements Dispatcher. 27 | dispatcher struct { 28 | // client is the Client that this dispatcher belongs to. 29 | client *Client 30 | 31 | // log is the logger. 32 | log Logger 33 | 34 | // ctx stores the context used to start the dispatcher. 35 | ctx context.Context 36 | 37 | // shutdownCtx stores an internal context that is used when attempting to gracefully shut down the dispatcher. 38 | shutdownCtx context.Context 39 | 40 | // shutdown is the cancel function for cancelling shutdownCtx. 41 | shutdown context.CancelFunc 42 | 43 | // numWorkers is the amount of goroutines opened to execute tasks. 44 | numWorkers int 45 | 46 | // releaseAfter is the duration to reclaim a task for execution if it has not completed. 47 | releaseAfter time.Duration 48 | 49 | // CleanupInterval is how often to run cleanup operations on the database in order to remove expired completed 50 | // tasks. 51 | cleanupInterval time.Duration 52 | 53 | // running indicates if the dispatching is currently running. 54 | running atomic.Bool 55 | 56 | // ticker will fetch tasks from the database if the next task is delayed. 57 | ticker *time.Ticker 58 | 59 | // tasks transmits tasks to the workers. 60 | tasks chan *task.Task 61 | 62 | // availableWorkers tracks the amount of workers available to receive a task to execute. 63 | availableWorkers chan struct{} 64 | 65 | // ready tells the dispatcher that fetching tasks from the database is required. 66 | ready chan struct{} 67 | 68 | // trigger instructs the dispatcher to fetch tasks from the database now. 69 | trigger chan struct{} 70 | 71 | // triggered indicates that a trigger was sent but not yet received. 72 | // This is used to allow multiple calls to ready, which will happen whenever a task is added, 73 | // but only 1 database fetch since that is all that is needed for the dispatcher to be aware of the 74 | // current state of the queues. 75 | triggered atomic.Bool 76 | } 77 | ) 78 | 79 | // Start starts the dispatcher. 80 | // To hard-stop, cancel the provided context. To gracefully stop, call stop(). 81 | func (d *dispatcher) Start(ctx context.Context) { 82 | // Abort if the dispatcher is already running. 83 | if d.running.Load() { 84 | return 85 | } 86 | 87 | d.ctx = ctx 88 | d.shutdownCtx, d.shutdown = context.WithCancel(context.Background()) 89 | d.tasks = make(chan *task.Task, d.numWorkers) 90 | d.ticker = time.NewTicker(time.Second) 91 | d.ticker.Stop() // No need to tick yet 92 | d.ready = make(chan struct{}, 1000) // Prevent blocking task creation 93 | d.trigger = make(chan struct{}, 10) // Should never need more than 1 but just in case 94 | d.availableWorkers = make(chan struct{}, d.numWorkers) 95 | d.running.Store(true) 96 | 97 | for range d.numWorkers { 98 | go d.worker() 99 | d.availableWorkers <- struct{}{} 100 | } 101 | 102 | if d.cleanupInterval > 0 { 103 | go d.cleaner() 104 | } 105 | 106 | go d.triggerer() 107 | go d.fetcher() 108 | 109 | d.ready <- struct{}{} 110 | 111 | d.log.Info("task dispatcher started") 112 | } 113 | 114 | // Stop attempts to gracefully shut down the dispatcher by blocking until either the context is cancelled or all 115 | // workers are done with their task. If all workers are able to complete, true will be returned. 116 | func (d *dispatcher) Stop(ctx context.Context) bool { 117 | if !d.running.Load() { 118 | return true 119 | } 120 | 121 | // Call the internal shutdown to gracefully close all goroutines. 122 | d.shutdown() 123 | 124 | var count int 125 | 126 | for { 127 | select { 128 | case <-ctx.Done(): 129 | return false 130 | 131 | case <-d.availableWorkers: 132 | count++ 133 | 134 | if count == d.numWorkers { 135 | return true 136 | } 137 | } 138 | } 139 | } 140 | 141 | // triggerer listens to the ready channel and sends a trigger to the fetcher only when it is needed which is 142 | // controlled by the triggered lock. This allows the dispatcher to track database fetches and when one is made, 143 | // it can account for all incoming tasks that sent a signal to the ready channel before it, rather than fetching 144 | // from the database every single time a new task is added. 145 | func (d *dispatcher) triggerer() { 146 | for { 147 | select { 148 | case <-d.ready: 149 | if d.triggered.CompareAndSwap(false, true) { 150 | d.trigger <- struct{}{} 151 | } 152 | 153 | case <-d.shutdownCtx.Done(): 154 | return 155 | 156 | case <-d.ctx.Done(): 157 | return 158 | } 159 | } 160 | } 161 | 162 | // fetcher fetches tasks from the database to be executed either when the ticker ticks or when the trigger signal 163 | // is sent by the triggerer. 164 | func (d *dispatcher) fetcher() { 165 | defer func() { 166 | d.running.Store(false) 167 | d.ticker.Stop() 168 | close(d.tasks) 169 | d.log.Info("shutting down dispatcher") 170 | }() 171 | 172 | for { 173 | select { 174 | case <-d.ticker.C: 175 | d.ticker.Stop() 176 | d.fetch() 177 | 178 | case <-d.trigger: 179 | d.fetch() 180 | 181 | case <-d.shutdownCtx.Done(): 182 | return 183 | 184 | case <-d.ctx.Done(): 185 | return 186 | } 187 | } 188 | } 189 | 190 | // worker processes incoming tasks. 191 | func (d *dispatcher) worker() { 192 | for { 193 | select { 194 | case row := <-d.tasks: 195 | if row == nil { 196 | break 197 | } 198 | d.processTask(row) 199 | d.availableWorkers <- struct{}{} 200 | 201 | case <-d.shutdownCtx.Done(): 202 | return 203 | 204 | case <-d.ctx.Done(): 205 | return 206 | } 207 | } 208 | } 209 | 210 | // cleaner periodically deletes expired completed tasks from the database. 211 | func (d *dispatcher) cleaner() { 212 | ticker := time.NewTicker(d.cleanupInterval) 213 | 214 | for { 215 | select { 216 | case <-ticker.C: 217 | if err := task.DeleteExpiredCompleted(d.ctx, d.client.db); err != nil { 218 | d.log.Error("failed to delete expired completed tasks", 219 | "error", err, 220 | ) 221 | } 222 | 223 | case <-d.shutdownCtx.Done(): 224 | return 225 | 226 | case <-d.ctx.Done(): 227 | ticker.Stop() 228 | return 229 | } 230 | } 231 | } 232 | 233 | // waitForWorkers waits until at least one worker is available to execute a task and returns the number that are 234 | // available. 235 | func (d *dispatcher) waitForWorkers() int { 236 | for { 237 | if w := len(d.availableWorkers); w > 0 { 238 | return w 239 | } 240 | time.Sleep(100 * time.Millisecond) 241 | } 242 | } 243 | 244 | // fetch fetches tasks from the database to be executed and/or coordinate the dispatcher, so it is aware of when it 245 | // needs to fetch again. 246 | func (d *dispatcher) fetch() { 247 | var err error 248 | 249 | // If we failed at any point, we need to tell the dispatcher to try again. 250 | defer func() { 251 | if err != nil { 252 | // Wait and try again. 253 | time.Sleep(100 * time.Millisecond) 254 | d.ready <- struct{}{} 255 | } 256 | }() 257 | 258 | // Indicate that incoming task additions from this point on should trigger another fetch. 259 | d.triggered.Store(false) 260 | 261 | // Determine how many workers are available, so we only fetch that many tasks. 262 | workers := d.waitForWorkers() 263 | 264 | // Fetch tasks for each available worker plus the next upcoming task so the scheduler knows when to 265 | // query the database again without having to continually poll. 266 | tasks, err := task.GetScheduledTasks( 267 | d.ctx, 268 | d.client.db, 269 | now().Add(-d.releaseAfter), 270 | int(workers)+1, 271 | ) 272 | 273 | if err != nil { 274 | d.log.Error("fetch tasks query failed", 275 | "error", err, 276 | ) 277 | return 278 | } 279 | 280 | var next *task.Task 281 | nextUp := func(i int) { 282 | next = tasks[i] 283 | tasks = tasks[:i] 284 | } 285 | 286 | for i := range tasks { 287 | // Check if the workers are full. 288 | if (i + 1) > workers { 289 | nextUp(i) 290 | break 291 | } 292 | 293 | // Check if this task is not ready yet. 294 | if tasks[i].WaitUntil != nil { 295 | if tasks[i].WaitUntil.After(now()) { 296 | nextUp(i) 297 | break 298 | } 299 | } 300 | } 301 | 302 | // Claim the tasks that are ready to be processed. 303 | if err = tasks.Claim(d.ctx, d.client.db); err != nil { 304 | d.log.Error("failed to claim tasks", 305 | "error", err, 306 | ) 307 | return 308 | } 309 | 310 | // Send the ready tasks to the workers. 311 | for i := range tasks { 312 | tasks[i].Attempts++ 313 | <-d.availableWorkers 314 | d.tasks <- tasks[i] 315 | } 316 | 317 | // Adjust the schedule based on the next up task. 318 | d.schedule(next) 319 | } 320 | 321 | // schedule handles scheduling the dispatcher based on the next up task provided by the fetcher. 322 | func (d *dispatcher) schedule(t *task.Task) { 323 | d.ticker.Stop() 324 | 325 | if t != nil { 326 | if t.WaitUntil == nil { 327 | d.ready <- struct{}{} 328 | return 329 | } 330 | 331 | dur := t.WaitUntil.Sub(now()) 332 | if dur < 0 { 333 | d.ready <- struct{}{} 334 | return 335 | } 336 | 337 | d.ticker.Reset(dur) 338 | } 339 | } 340 | 341 | // processTask attempts to execute a given task. 342 | func (d *dispatcher) processTask(t *task.Task) { 343 | var err error 344 | var ctx context.Context 345 | var cancel context.CancelFunc 346 | 347 | q := d.client.queues.get(t.Queue) 348 | cfg := q.Config() 349 | 350 | // Set a context timeout, if desired. 351 | if cfg.Timeout > 0 { 352 | ctx, cancel = context.WithDeadline(d.ctx, now().Add(cfg.Timeout)) 353 | defer cancel() 354 | } else { 355 | ctx = d.ctx 356 | } 357 | 358 | // Store the client in the context so the processor can use it. 359 | // TODO include the attempt number 360 | ctx = context.WithValue(ctx, ctxKeyClient{}, d.client) 361 | 362 | start := now() 363 | 364 | defer func() { 365 | // Recover from panics from within the task processor. 366 | if rec := recover(); rec != nil { 367 | d.log.Error("panic processing task", 368 | "id", t.ID, 369 | "queue", t.Queue, 370 | "error", rec, 371 | ) 372 | 373 | err = fmt.Errorf("%v", rec) 374 | } 375 | 376 | // If panic or error, handle the task as a failure. 377 | if err != nil { 378 | d.taskFailure(q, t, start, time.Since(start), err) 379 | } 380 | }() 381 | 382 | // Process the task. 383 | if err = q.Process(ctx, t.Task); err == nil { 384 | d.taskSuccess(q, t, start, time.Since(start)) 385 | } 386 | } 387 | 388 | // taskSuccess handles post successful execution of a given task by removing it from the task table and optionally 389 | // retaining it in the completed tasks table if the queue settings have retention enabled. 390 | func (d *dispatcher) taskSuccess(q Queue, t *task.Task, started time.Time, dur time.Duration) { 391 | var tx *sql.Tx 392 | var err error 393 | 394 | defer func() { 395 | if err != nil { 396 | d.log.Error("failed to update task success", 397 | "id", t.ID, 398 | "queue", t.Queue, 399 | "error", err, 400 | ) 401 | 402 | if tx != nil { 403 | if err := tx.Rollback(); err != nil { 404 | d.log.Error("failed to rollback task success", 405 | "id", t.ID, 406 | "queue", t.Queue, 407 | "error", err, 408 | ) 409 | } 410 | } 411 | } 412 | }() 413 | 414 | d.log.Info("task processed", 415 | "id", t.ID, 416 | "queue", t.Queue, 417 | "duration", dur, 418 | "attempt", t.Attempts, 419 | ) 420 | 421 | tx, err = d.client.db.Begin() 422 | if err != nil { 423 | return 424 | } 425 | 426 | err = t.DeleteTx(d.ctx, tx) 427 | if err != nil { 428 | return 429 | } 430 | 431 | if err = d.taskComplete(tx, q, t, started, dur, nil); err != nil { 432 | return 433 | } 434 | 435 | err = tx.Commit() 436 | } 437 | 438 | // taskFailure handles post failed execution of a given task by either releasing it back to the queue, if the maximum 439 | // amount of attempts haven't been reached, or by deleting it from the task table and optionally moving to the completed 440 | // task table if the queue has retention enabled. 441 | func (d *dispatcher) taskFailure(q Queue, t *task.Task, started time.Time, dur time.Duration, taskErr error) { 442 | remaining := q.Config().MaxAttempts - t.Attempts 443 | 444 | d.log.Error("task processing failed", 445 | "id", t.ID, 446 | "queue", t.Queue, 447 | "duration", dur, 448 | "attempt", t.Attempts, 449 | "remaining", remaining, 450 | ) 451 | 452 | if remaining < 1 { 453 | var tx *sql.Tx 454 | var err error 455 | 456 | defer func() { 457 | if err != nil { 458 | d.log.Error("failed to update task failure", 459 | "id", t.ID, 460 | "queue", t.Queue, 461 | "error", err, 462 | ) 463 | 464 | if tx != nil { 465 | if err := tx.Rollback(); err != nil { 466 | d.log.Error("failed to rollback task failure", 467 | "id", t.ID, 468 | "queue", t.Queue, 469 | "error", err, 470 | ) 471 | } 472 | } 473 | } 474 | }() 475 | 476 | tx, err = d.client.db.Begin() 477 | if err != nil { 478 | return 479 | } 480 | 481 | err = t.DeleteTx(d.ctx, tx) 482 | if err != nil { 483 | return 484 | } 485 | 486 | if err = d.taskComplete(tx, q, t, started, dur, taskErr); err != nil { 487 | return 488 | } 489 | 490 | err = tx.Commit() 491 | } else { 492 | t.LastExecutedAt = &started 493 | 494 | err := t.Fail( 495 | d.ctx, 496 | d.client.db, 497 | now().Add(q.Config().Backoff), 498 | ) 499 | 500 | if err != nil { 501 | d.log.Error("failed to update task failure", 502 | "id", t.ID, 503 | "queue", t.Queue, 504 | "error", err, 505 | ) 506 | } 507 | 508 | d.ready <- struct{}{} 509 | } 510 | } 511 | 512 | // taskComplete creates a completed task from a given task. 513 | func (d *dispatcher) taskComplete( 514 | tx *sql.Tx, 515 | q Queue, 516 | t *task.Task, 517 | started time.Time, 518 | dur time.Duration, 519 | taskErr error) error { 520 | ret := q.Config().Retention 521 | if ret == nil { 522 | return nil 523 | } 524 | 525 | if taskErr == nil && ret.OnlyFailed { 526 | return nil 527 | } 528 | 529 | c := task.Completed{ 530 | ID: t.ID, 531 | Queue: t.Queue, 532 | Attempts: t.Attempts, 533 | Succeeded: taskErr == nil, 534 | LastDuration: dur, 535 | CreatedAt: t.CreatedAt, 536 | LastExecutedAt: started, 537 | } 538 | 539 | if taskErr != nil { 540 | errStr := taskErr.Error() 541 | c.Error = &errStr 542 | } 543 | 544 | if ret.Duration != 0 { 545 | v := now().Add(ret.Duration) 546 | c.ExpiresAt = &v 547 | } 548 | 549 | if ret.Data != nil { 550 | if !ret.Data.OnlyFailed || taskErr != nil { 551 | c.Task = t.Task 552 | } 553 | } 554 | 555 | return c.InsertTx(d.ctx, tx) 556 | } 557 | 558 | // Notify is used by the client to notify the dispatcher that a new task was added. 559 | func (d *dispatcher) Notify() { 560 | if d.running.Load() { 561 | d.ready <- struct{}{} 562 | } 563 | } 564 | -------------------------------------------------------------------------------- /dispatcher_test.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | "testing" 10 | "time" 11 | 12 | "github.com/mikestefanello/backlite/internal/task" 13 | "github.com/mikestefanello/backlite/internal/testutil" 14 | ) 15 | 16 | func TestDispatcher_Notify(t *testing.T) { 17 | d := dispatcher{ 18 | ready: make(chan struct{}, 1), 19 | } 20 | 21 | d.Notify() 22 | select { 23 | case <-d.ready: 24 | t.Error("ready message was sent") 25 | default: 26 | } 27 | 28 | d.running.Store(true) 29 | d.Notify() 30 | select { 31 | case <-d.ready: 32 | default: 33 | t.Error("ready message was not sent") 34 | } 35 | } 36 | 37 | func TestDispatcher_Start(t *testing.T) { 38 | d := newDispatcher(t) 39 | 40 | // Start while already started. 41 | d.running.Store(true) 42 | d.Start(context.Background()) 43 | testutil.Equal(t, "ctx", nil, d.ctx) 44 | testutil.Equal(t, "tasks channel", 0, cap(d.tasks)) 45 | testutil.Equal(t, "ready channel", 0, cap(d.ready)) 46 | testutil.Equal(t, "trigger channel", 0, cap(d.trigger)) 47 | testutil.Equal(t, "available workers channel", 0, cap(d.availableWorkers)) 48 | testutil.Equal(t, "available workers channel length", 0, len(d.availableWorkers)) 49 | testutil.Equal(t, "running", true, d.running.Load()) 50 | 51 | // Start when not yet started. 52 | d.running.Store(false) 53 | ctx, cancel := context.WithCancel(context.Background()) 54 | d.Start(ctx) 55 | testutil.Equal(t, "ctx", ctx, d.ctx) 56 | testutil.Equal(t, "tasks channel", d.numWorkers, cap(d.tasks)) 57 | testutil.Equal(t, "ready channel", 1000, cap(d.ready)) 58 | testutil.Equal(t, "trigger channel", 10, cap(d.trigger)) 59 | testutil.Equal(t, "available workers channel", d.numWorkers, cap(d.availableWorkers)) 60 | testutil.Equal(t, "available workers channel length", d.numWorkers, len(d.availableWorkers)) 61 | testutil.Equal(t, "running", true, d.running.Load()) 62 | 63 | // Context cancel should shut down. 64 | cancel() 65 | testutil.Wait() 66 | testutil.Equal(t, "running", false, d.running.Load()) 67 | 68 | // Check that the ready signal was sent by forcing the goroutines to close. 69 | ctx, cancel = context.WithCancel(context.Background()) 70 | cancel() 71 | d.Start(ctx) 72 | testutil.WaitForChan(t, d.ready) 73 | } 74 | 75 | func TestDispatcher_Stop(t *testing.T) { 76 | d := newDispatcher(t) 77 | ctx := context.Background() 78 | 79 | // Not running. 80 | got := d.Stop(ctx) 81 | testutil.Equal(t, "shutdown", true, got) 82 | testutil.Equal(t, "running", false, d.running.Load()) 83 | 84 | // All workers are free. 85 | d.Start(ctx) 86 | got = d.Stop(ctx) 87 | testutil.Wait() 88 | testutil.Wait() 89 | testutil.Equal(t, "shutdown", true, got) 90 | testutil.Equal(t, "running", false, d.running.Load()) 91 | select { 92 | case <-d.shutdownCtx.Done(): 93 | default: 94 | t.Error("shutdown context was not cancelled") 95 | } 96 | 97 | // One worker is not free. 98 | d.Start(ctx) 99 | <-d.availableWorkers 100 | ctx, cancel := context.WithTimeout(ctx, 10*time.Millisecond) 101 | defer cancel() 102 | got = d.Stop(ctx) 103 | testutil.Wait() 104 | testutil.Wait() 105 | testutil.Equal(t, "shutdown", false, got) 106 | testutil.Equal(t, "running", false, d.running.Load()) 107 | } 108 | 109 | func TestDispatcher_Triggerer(t *testing.T) { 110 | ctx, cancel := context.WithCancel(context.Background()) 111 | d := &dispatcher{ 112 | ready: make(chan struct{}, 5), 113 | trigger: make(chan struct{}, 5), 114 | shutdownCtx: context.Background(), 115 | ctx: ctx, 116 | } 117 | go d.triggerer() 118 | 119 | // Send one and expect one trigger. 120 | d.ready <- struct{}{} 121 | testutil.WaitForChan(t, d.trigger) 122 | 123 | d.triggered.Store(false) 124 | 125 | // Send multiple and still expect only one. 126 | d.ready <- struct{}{} 127 | d.ready <- struct{}{} 128 | d.ready <- struct{}{} 129 | testutil.Wait() 130 | if len(d.trigger) != 1 { 131 | t.Fatalf("trigger contains %d, not 1", len(d.trigger)) 132 | } 133 | 134 | <-d.trigger 135 | d.triggered.Store(false) 136 | 137 | // Shutdown main context and expect nothing. 138 | cancel() 139 | d.ready <- struct{}{} 140 | testutil.Wait() 141 | if len(d.trigger) != 0 { 142 | t.Fatalf("trigger contains %d, not 0", len(d.trigger)) 143 | } 144 | 145 | // Shutdown graceful context and expect nothing. 146 | ctx, cancel = context.WithCancel(context.Background()) 147 | d = &dispatcher{ 148 | ready: make(chan struct{}, 5), 149 | trigger: make(chan struct{}, 5), 150 | shutdownCtx: ctx, 151 | ctx: context.Background(), 152 | } 153 | go d.triggerer() 154 | 155 | cancel() 156 | testutil.Wait() 157 | d.ready <- struct{}{} 158 | testutil.Wait() 159 | if len(d.trigger) != 0 { 160 | t.Fatalf("trigger contains %d, not 0", len(d.trigger)) 161 | } 162 | } 163 | 164 | func TestDispatcher_Cleaner(t *testing.T) { 165 | db := testutil.NewDB(t) 166 | defer db.Close() 167 | 168 | d := &dispatcher{ 169 | numWorkers: 1, 170 | client: &Client{db: db}, 171 | log: &noLogger{}, 172 | } 173 | 174 | tc := task.Completed{ 175 | Queue: "test", 176 | Task: nil, 177 | Attempts: 1, 178 | Succeeded: false, 179 | LastDuration: 0, 180 | CreatedAt: time.Now(), 181 | LastExecutedAt: time.Now(), 182 | Error: nil, 183 | } 184 | 185 | // Disabled. 186 | d.Start(context.Background()) 187 | tc.ID = "1" 188 | tc.ExpiresAt = testutil.Pointer(time.Now()) 189 | testutil.InsertCompleted(t, db, tc) 190 | testutil.Wait() 191 | testutil.Equal(t, "", len(testutil.GetCompletedTasks(t, db)), 1) 192 | d.Stop(context.Background()) 193 | testutil.Wait() 194 | 195 | // Enabled. 196 | d.cleanupInterval = 2 * time.Millisecond 197 | d.Start(context.Background()) 198 | testutil.Wait() 199 | testutil.Equal(t, "", len(testutil.GetCompletedTasks(t, db)), 0) 200 | d.Stop(context.Background()) 201 | testutil.Wait() 202 | 203 | // Enable again but with different expiration conditions. 204 | tc.ID = "2" 205 | tc.ExpiresAt = nil 206 | testutil.InsertCompleted(t, db, tc) 207 | tc.ID = "3" 208 | tc.ExpiresAt = testutil.Pointer(time.Now().Add(time.Hour)) 209 | testutil.InsertCompleted(t, db, tc) 210 | tc.ID = "4" 211 | tc.ExpiresAt = testutil.Pointer(time.Now().Add(time.Millisecond)) 212 | testutil.InsertCompleted(t, db, tc) 213 | tc.ID = "5" 214 | tc.ExpiresAt = testutil.Pointer(time.Now().Add(150 * time.Millisecond)) 215 | testutil.InsertCompleted(t, db, tc) 216 | d.Start(context.Background()) 217 | testutil.Wait() 218 | testutil.CompleteTaskIDsExist(t, db, []string{"2", "3", "5"}) 219 | testutil.Wait() 220 | testutil.CompleteTaskIDsExist(t, db, []string{"2", "3"}) 221 | d.Stop(context.Background()) 222 | } 223 | 224 | func TestDispatcher_ProcessTask__Context(t *testing.T) { 225 | ctx, cancel := context.WithCancel(context.Background()) 226 | var innerCtx context.Context 227 | d := newDispatcher(t) 228 | d.ctx = ctx 229 | var called bool 230 | 231 | d.client.Register(NewQueue[testTask](func(ctx context.Context, _ testTask) error { 232 | called = true 233 | innerCtx = ctx 234 | deadline, ok := ctx.Deadline() 235 | testutil.Equal(t, "deadline set", true, ok) 236 | testutil.Equal(t, "client", d.client, FromContext(ctx)) 237 | 238 | if deadline.Sub(now()) != time.Second { 239 | t.Error("ctx deadline too large") 240 | } 241 | return nil 242 | })) 243 | 244 | d.processTask(&task.Task{ 245 | ID: "1", 246 | Queue: "test", 247 | Task: testutil.Encode(t, &testTask{Val: "1"}), 248 | Attempts: 1, 249 | CreatedAt: time.Now(), 250 | }) 251 | testutil.Equal(t, "called", true, called) 252 | 253 | cancel() 254 | select { 255 | case <-innerCtx.Done(): 256 | default: 257 | t.Error("cancel did not cancel inner context") 258 | } 259 | } 260 | 261 | func TestDispatcher_ProcessTask__Success(t *testing.T) { 262 | d := newDispatcher(t) 263 | d.ready = make(chan struct{}, 1) 264 | d.ctx = context.Background() 265 | var called bool 266 | 267 | d.client.Register(NewQueue[testTask](func(ctx context.Context, tk testTask) error { 268 | called = true 269 | testutil.Equal(t, "task val", "1", tk.Val) 270 | return nil 271 | })) 272 | 273 | tk := &task.Task{ 274 | ID: "4", 275 | Queue: "test", 276 | Task: testutil.Encode(t, &testTask{Val: "1"}), 277 | Attempts: 1, 278 | CreatedAt: now(), 279 | } 280 | testutil.InsertTask(t, d.client.db, tk) 281 | 282 | d.processTask(tk) 283 | testutil.Equal(t, "called", true, called) 284 | 285 | got := testutil.GetTasks(t, d.client.db) 286 | testutil.Length(t, got, 0) 287 | testutil.Equal(t, "ready", 0, len(d.ready)) 288 | 289 | ct := testutil.GetCompletedTasks(t, d.client.db) 290 | testutil.Length(t, ct, 1) 291 | 292 | testutil.Equal(t, "id", "4", ct[0].ID) 293 | testutil.Equal(t, "queue", "test", ct[0].Queue) 294 | testutil.Equal(t, "attempts", 1, ct[0].Attempts) 295 | testutil.Equal(t, "succeeded", true, ct[0].Succeeded) 296 | testutil.Equal(t, "created at", now(), ct[0].CreatedAt) 297 | testutil.Equal(t, "last executed at", now(), ct[0].LastExecutedAt) 298 | testutil.Equal(t, "expires at", now().Add(time.Hour), *ct[0].ExpiresAt) 299 | testutil.Equal(t, "error", nil, ct[0].Error) 300 | 301 | if !bytes.Equal(ct[0].Task, tk.Task) { 302 | t.Error("task does not match") 303 | } 304 | 305 | if ct[0].LastDuration <= 0 { 306 | t.Error("last duration not set") 307 | } 308 | } 309 | 310 | func TestDispatcher_ProcessTask__NoRention(t *testing.T) { 311 | d := newDispatcher(t) 312 | d.ready = make(chan struct{}, 1) 313 | d.ctx = context.Background() 314 | 315 | d.client.Register(NewQueue[testTaskNoRention](func(ctx context.Context, _ testTaskNoRention) error { 316 | return nil 317 | })) 318 | 319 | tk := &task.Task{ 320 | ID: "5", 321 | Queue: "test-noret", 322 | Task: testutil.Encode(t, &testTaskNoRention{Val: "1"}), 323 | Attempts: 1, 324 | CreatedAt: now(), 325 | } 326 | testutil.InsertTask(t, d.client.db, tk) 327 | 328 | d.processTask(tk) 329 | 330 | got := testutil.GetTasks(t, d.client.db) 331 | testutil.Length(t, got, 0) 332 | 333 | ct := testutil.GetCompletedTasks(t, d.client.db) 334 | testutil.Length(t, ct, 0) 335 | } 336 | 337 | func TestDispatcher_ProcessTask__RetainNoData(t *testing.T) { 338 | d := newDispatcher(t) 339 | d.ready = make(chan struct{}, 1) 340 | d.ctx = context.Background() 341 | 342 | d.client.Register(NewQueue[testTaskRetainNoData](func(ctx context.Context, _ testTaskRetainNoData) error { 343 | return nil 344 | })) 345 | 346 | tk := &task.Task{ 347 | ID: "5", 348 | Queue: "test-retainnodata", 349 | Task: testutil.Encode(t, &testTaskRetainNoData{Val: "1"}), 350 | Attempts: 1, 351 | CreatedAt: now(), 352 | } 353 | testutil.InsertTask(t, d.client.db, tk) 354 | 355 | d.processTask(tk) 356 | 357 | got := testutil.GetTasks(t, d.client.db) 358 | testutil.Length(t, got, 0) 359 | 360 | ct := testutil.GetCompletedTasks(t, d.client.db) 361 | testutil.Length(t, ct, 1) 362 | 363 | if ct[0].Task != nil { 364 | t.Error("task data shouldn't have been retained") 365 | } 366 | } 367 | 368 | func TestDispatcher_ProcessTask__RetainForever(t *testing.T) { 369 | d := newDispatcher(t) 370 | d.ready = make(chan struct{}, 1) 371 | d.ctx = context.Background() 372 | 373 | d.client.Register(NewQueue[testTaskRentainForever](func(ctx context.Context, _ testTaskRentainForever) error { 374 | return nil 375 | })) 376 | 377 | tk := &task.Task{ 378 | ID: "5", 379 | Queue: "test-retainforever", 380 | Task: testutil.Encode(t, &testTaskRentainForever{Val: "1"}), 381 | Attempts: 1, 382 | CreatedAt: now(), 383 | } 384 | testutil.InsertTask(t, d.client.db, tk) 385 | 386 | d.processTask(tk) 387 | 388 | got := testutil.GetTasks(t, d.client.db) 389 | testutil.Length(t, got, 0) 390 | 391 | ct := testutil.GetCompletedTasks(t, d.client.db) 392 | testutil.Length(t, ct, 1) 393 | testutil.Equal(t, "expires at", nil, ct[0].ExpiresAt) 394 | } 395 | 396 | func TestDispatcher_ProcessTask__RetainDataFailed(t *testing.T) { 397 | d := newDispatcher(t) 398 | d.ready = make(chan struct{}, 1) 399 | d.ctx = context.Background() 400 | var succeed bool 401 | 402 | d.client.Register(NewQueue[testTaskRetainDataFailed](func(ctx context.Context, _ testTaskRetainDataFailed) error { 403 | if succeed { 404 | return nil 405 | } 406 | return errors.New("fail") 407 | })) 408 | 409 | for _, b := range []bool{true, false} { 410 | succeed = b 411 | tk := &task.Task{ 412 | ID: strconv.FormatBool(succeed), 413 | Queue: "test-retaindatafailed", 414 | Task: testutil.Encode(t, &testTaskRetainDataFailed{Val: "1"}), 415 | Attempts: 2, 416 | CreatedAt: now(), 417 | } 418 | testutil.InsertTask(t, d.client.db, tk) 419 | 420 | d.processTask(tk) 421 | 422 | got := testutil.GetTasks(t, d.client.db) 423 | testutil.Length(t, got, 0) 424 | 425 | ct := testutil.GetCompletedTasks(t, d.client.db) 426 | testutil.Length(t, ct, 1) 427 | 428 | if succeed { 429 | if ct[0].Task != nil { 430 | t.Error("task data shouldn't have been retained") 431 | } 432 | } else { 433 | if ct[0].Task == nil { 434 | t.Error("task data should have been retained") 435 | } 436 | } 437 | 438 | testutil.DeleteCompletedTasks(t, d.client.db) 439 | } 440 | } 441 | 442 | func TestDispatcher_ProcessTask__RetainFailed(t *testing.T) { 443 | d := newDispatcher(t) 444 | d.ready = make(chan struct{}, 1) 445 | d.ctx = context.Background() 446 | var succeed bool 447 | 448 | d.client.Register(NewQueue[testTaskRetainFailed](func(ctx context.Context, _ testTaskRetainFailed) error { 449 | if succeed { 450 | return nil 451 | } 452 | return errors.New("fail") 453 | })) 454 | 455 | for _, b := range []bool{true, false} { 456 | succeed = b 457 | tk := &task.Task{ 458 | ID: strconv.FormatBool(succeed), 459 | Queue: "test-retainfailed", 460 | Task: testutil.Encode(t, &testTaskRetainFailed{Val: "1"}), 461 | Attempts: 2, 462 | CreatedAt: now(), 463 | } 464 | testutil.InsertTask(t, d.client.db, tk) 465 | 466 | d.processTask(tk) 467 | 468 | got := testutil.GetTasks(t, d.client.db) 469 | testutil.Length(t, got, 0) 470 | 471 | ct := testutil.GetCompletedTasks(t, d.client.db) 472 | 473 | if succeed { 474 | testutil.Length(t, ct, 0) 475 | } else { 476 | testutil.Length(t, ct, 1) 477 | } 478 | } 479 | } 480 | 481 | func TestDispatcher_ProcessTask__Panic(t *testing.T) { 482 | d := newDispatcher(t) 483 | d.ready = make(chan struct{}, 1) 484 | d.ctx = context.Background() 485 | var called bool 486 | 487 | d.client.Register(NewQueue[testTask](func(ctx context.Context, _ testTask) error { 488 | called = true 489 | panic("panic called") 490 | return nil 491 | })) 492 | 493 | tk := &task.Task{ 494 | ID: "2", 495 | Queue: "test", 496 | Task: testutil.Encode(t, &testTask{Val: "1"}), 497 | CreatedAt: now(), 498 | } 499 | testutil.InsertTask(t, d.client.db, tk) 500 | 501 | d.processTask(tk) 502 | testutil.Equal(t, "called", true, called) 503 | testutil.WaitForChan(t, d.ready) 504 | 505 | got := testutil.GetTasks(t, d.client.db) 506 | testutil.Length(t, got, 1) 507 | testutil.Equal(t, "last executed at", now(), *got[0].LastExecutedAt) 508 | testutil.Equal(t, "wait until", now().Add(5*time.Millisecond), *got[0].WaitUntil) 509 | } 510 | 511 | func TestDispatcher_ProcessTask__Failure(t *testing.T) { 512 | d := newDispatcher(t) 513 | d.ready = make(chan struct{}, 1) 514 | d.ctx = context.Background() 515 | var called bool 516 | 517 | d.client.Register(NewQueue[testTask](func(ctx context.Context, _ testTask) error { 518 | called = true 519 | return errors.New("failure error") 520 | })) 521 | 522 | tk := &task.Task{ 523 | ID: "3", 524 | Queue: "test", 525 | Task: testutil.Encode(t, &testTask{Val: "1"}), 526 | Attempts: 1, 527 | CreatedAt: now(), 528 | } 529 | testutil.InsertTask(t, d.client.db, tk) 530 | 531 | // First attempt. 532 | d.processTask(tk) 533 | testutil.Equal(t, "called", true, called) 534 | testutil.WaitForChan(t, d.ready) 535 | 536 | got := testutil.GetTasks(t, d.client.db) 537 | testutil.Length(t, got, 1) 538 | testutil.Equal(t, "last executed at", now(), *got[0].LastExecutedAt) 539 | testutil.Equal(t, "wait until", now().Add(5*time.Millisecond), *got[0].WaitUntil) 540 | 541 | // Final attempt. 542 | called = false 543 | tk.Attempts++ 544 | d.processTask(tk) 545 | testutil.Equal(t, "called", true, called) 546 | testutil.Equal(t, "ready", 0, len(d.ready)) 547 | got = testutil.GetTasks(t, d.client.db) 548 | testutil.Length(t, got, 0) 549 | 550 | ct := testutil.GetCompletedTasks(t, d.client.db) 551 | testutil.Length(t, ct, 1) 552 | 553 | testutil.Equal(t, "id", "3", ct[0].ID) 554 | testutil.Equal(t, "queue", "test", ct[0].Queue) 555 | testutil.Equal(t, "attempts", 2, ct[0].Attempts) 556 | testutil.Equal(t, "succeeded", false, ct[0].Succeeded) 557 | testutil.Equal(t, "created at", now(), ct[0].CreatedAt) 558 | testutil.Equal(t, "last executed at", now(), ct[0].LastExecutedAt) 559 | testutil.Equal(t, "expires at", now().Add(time.Hour), *ct[0].ExpiresAt) 560 | testutil.Equal(t, "error", "failure error", *ct[0].Error) 561 | 562 | if !bytes.Equal(ct[0].Task, tk.Task) { 563 | t.Error("task does not match") 564 | } 565 | 566 | if ct[0].LastDuration <= 0 { 567 | t.Error("last duration not set") 568 | } 569 | } 570 | 571 | func TestDispatcher_Fetcher(t *testing.T) { 572 | ctx, cancel := context.WithCancel(context.Background()) 573 | defer cancel() 574 | d := newDispatcher(t) 575 | d.ctx = ctx 576 | d.shutdownCtx = ctx 577 | d.ticker = time.NewTicker(1 * time.Hour) 578 | d.tasks = make(chan *task.Task, d.numWorkers) 579 | d.ready = make(chan struct{}, 1) 580 | d.trigger = make(chan struct{}, 1) 581 | d.availableWorkers = make(chan struct{}, d.numWorkers) 582 | hold := make(chan struct{}, d.numWorkers) 583 | 584 | for range d.numWorkers { 585 | go d.worker() 586 | d.availableWorkers <- struct{}{} 587 | } 588 | 589 | d.client.Register(NewQueue[testTask](func(ctx context.Context, t testTask) error { 590 | // Hold so we can test that the tasks were claimed. 591 | <-hold 592 | return nil 593 | })) 594 | 595 | for i := 0; i < 5; i++ { 596 | tk := &task.Task{ 597 | ID: fmt.Sprint(i + 1), 598 | Queue: "test", 599 | Task: testutil.Encode(t, &testTask{Val: "1"}), 600 | Attempts: 0, 601 | CreatedAt: now(), 602 | } 603 | testutil.InsertTask(t, d.client.db, tk) 604 | } 605 | 606 | d.fetch() 607 | 608 | // Check that the tasks were claimed. 609 | rows, err := d.client.db.Query("SELECT id, claimed_at FROM backlite_tasks") 610 | if err != nil { 611 | t.Fatal(err) 612 | } 613 | 614 | var rowCount int 615 | for rows.Next() { 616 | rowCount++ 617 | var id string 618 | var claimedAt *int64 619 | 620 | if err := rows.Scan(&id, &claimedAt); err != nil { 621 | t.Fatal(err) 622 | } 623 | 624 | switch id { 625 | case "1", "2", "3": 626 | if claimedAt == nil { 627 | t.Error("task should have been claimed") 628 | } 629 | default: 630 | if claimedAt != nil { 631 | t.Error("task should not have been claimed") 632 | } 633 | } 634 | } 635 | testutil.Equal(t, "rows", 5, rowCount) 636 | 637 | // Release the processing. 638 | for i := 0; i < d.numWorkers; i++ { 639 | hold <- struct{}{} 640 | } 641 | 642 | // Wait for the workers to finish. 643 | for i := 0; i < d.numWorkers; i++ { 644 | <-d.availableWorkers 645 | } 646 | 647 | // The amount of tasks completed should match the amount of workers. 648 | testutil.TaskIDsExist(t, d.client.db, []string{"4", "5"}) 649 | testutil.CompleteTaskIDsExist(t, d.client.db, []string{"1", "2", "3"}) 650 | 651 | // Verify that the attempt count was incremented. 652 | for _, tk := range testutil.GetCompletedTasks(t, d.client.db) { 653 | testutil.Equal(t, "attempts", 1, tk.Attempts) 654 | } 655 | 656 | // The ready signal should be sent because the next up task is ready. 657 | testutil.WaitForChan(t, d.ready) 658 | 659 | // Delete the tasks and add one that is scheduled to test the timer. 660 | testutil.DeleteTasks(t, d.client.db) 661 | testutil.InsertTask(t, d.client.db, &task.Task{ 662 | ID: "6", 663 | Queue: "test", 664 | Task: testutil.Encode(t, &testTask{Val: "1"}), 665 | Attempts: 0, 666 | CreatedAt: now(), 667 | WaitUntil: testutil.Pointer(now().Add(100 * time.Millisecond)), 668 | }) 669 | hold <- struct{}{} 670 | d.availableWorkers <- struct{}{} 671 | d.fetch() 672 | 673 | select { 674 | case <-d.ticker.C: 675 | case <-time.After(250 * time.Millisecond): 676 | t.Error("ticker was not reset") 677 | } 678 | } 679 | 680 | func newDispatcher(t *testing.T) *dispatcher { 681 | return &dispatcher{ 682 | numWorkers: 3, 683 | log: &noLogger{}, 684 | client: mustNewClient(t), 685 | } 686 | } 687 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mikestefanello/backlite 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/PuerkitoBio/goquery v1.10.2 9 | github.com/google/uuid v1.6.0 10 | github.com/mattn/go-sqlite3 v1.14.28 11 | ) 12 | 13 | require ( 14 | github.com/andybalholm/cascadia v1.3.3 // indirect 15 | golang.org/x/net v0.39.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= 2 | github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= 3 | github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= 4 | github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 5 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 6 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 7 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 8 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 9 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 10 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 11 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 12 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 13 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 14 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 15 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 16 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 17 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 18 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 19 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 20 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 21 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 22 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 23 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 24 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 25 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 26 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 27 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 28 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 29 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 30 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 31 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 32 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 33 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 34 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 35 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 36 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 37 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 38 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 39 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 40 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 41 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 42 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 49 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 50 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 51 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 52 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 53 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 54 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 55 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 56 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 57 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 58 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 59 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 60 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 61 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 62 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 63 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 64 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 65 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 66 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 67 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 68 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 69 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 70 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 71 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 72 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 73 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 74 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 75 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 76 | -------------------------------------------------------------------------------- /internal/query/query.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | //go:embed schema.sql 10 | var Schema string 11 | 12 | const InsertTask = ` 13 | INSERT INTO backlite_tasks 14 | (id, created_at, queue, task, wait_until) 15 | VALUES (?, ?, ?, ?, ?) 16 | ` 17 | 18 | const SelectScheduledTasks = ` 19 | SELECT 20 | id, queue, task, attempts, wait_until, created_at, last_executed_at, null 21 | FROM 22 | backlite_tasks 23 | WHERE 24 | claimed_at IS NULL 25 | OR claimed_at < ? 26 | ORDER BY 27 | wait_until ASC, 28 | id ASC 29 | LIMIT ? 30 | OFFSET ? 31 | ` 32 | 33 | const DeleteTask = ` 34 | DELETE FROM backlite_tasks 35 | WHERE id = ? 36 | ` 37 | 38 | const InsertCompletedTask = ` 39 | INSERT INTO backlite_tasks_completed 40 | (id, created_at, queue, last_executed_at, attempts, last_duration_micro, succeeded, task, expires_at, error) 41 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 42 | ` 43 | 44 | const TaskFailed = ` 45 | UPDATE backlite_tasks 46 | SET 47 | claimed_at = NULL, 48 | wait_until = ?, 49 | last_executed_at = ? 50 | WHERE id = ? 51 | ` 52 | 53 | const DeleteExpiredCompletedTasks = ` 54 | DELETE FROM backlite_tasks_completed 55 | WHERE 56 | expires_at IS NOT NULL 57 | AND expires_at <= ? 58 | ` 59 | 60 | func ClaimTasks(count int) string { 61 | const query = ` 62 | UPDATE backlite_tasks 63 | SET 64 | claimed_at = ?, 65 | attempts = attempts + 1 66 | WHERE id IN (%s) 67 | ` 68 | 69 | param := strings.Repeat("?,", count) 70 | 71 | return fmt.Sprintf(query, param[:len(param)-1]) 72 | } 73 | -------------------------------------------------------------------------------- /internal/query/query_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import "testing" 4 | 5 | func TestClaimTasks(t *testing.T) { 6 | got := ClaimTasks(3) 7 | expected := ` 8 | UPDATE backlite_tasks 9 | SET 10 | claimed_at = ?, 11 | attempts = attempts + 1 12 | WHERE id IN (?,?,?) 13 | ` 14 | 15 | if got != expected { 16 | t.Errorf("expected\n%s\n,got:\n%s", expected, got) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/query/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS backlite_tasks ( 2 | id text PRIMARY KEY, 3 | created_at integer NOT NULL, 4 | queue text NOT NULL, 5 | task blob NOT NULL, 6 | wait_until integer, 7 | claimed_at integer, 8 | last_executed_at integer, 9 | attempts integer NOT NULL DEFAULT 0 10 | ) STRICT; 11 | 12 | CREATE TABLE IF NOT EXISTS backlite_tasks_completed ( 13 | id text PRIMARY KEY NOT NULL, 14 | created_at integer NOT NULL, 15 | queue text NOT NULL, 16 | last_executed_at integer, 17 | attempts integer NOT NULL, 18 | last_duration_micro integer, 19 | succeeded integer, 20 | task blob, 21 | expires_at integer, 22 | error text 23 | ) STRICT; 24 | 25 | CREATE INDEX IF NOT EXISTS backlite_tasks_wait_until ON backlite_tasks (wait_until) WHERE wait_until IS NOT NULL; -------------------------------------------------------------------------------- /internal/task/completed.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | 8 | "github.com/mikestefanello/backlite/internal/query" 9 | ) 10 | 11 | type ( 12 | // Completed is a completed task. 13 | Completed struct { 14 | // ID is the Task ID 15 | ID string 16 | 17 | // Queue is the name of the queue this Task belongs to. 18 | Queue string 19 | 20 | // Task is the task data. 21 | Task []byte 22 | 23 | // Attempts are the amount of times this Task was executed. 24 | Attempts int 25 | 26 | // Succeeded indicates if the Task execution was a success. 27 | Succeeded bool 28 | 29 | // LastDuration is the last execution duration. 30 | LastDuration time.Duration 31 | 32 | // ExpiresAt is when this record should be removed from the database. 33 | // If omitted, the record should not be removed. 34 | ExpiresAt *time.Time 35 | 36 | // CreatedAt is when the Task was originally created. 37 | CreatedAt time.Time 38 | 39 | // LastExecutedAt is the last time this Task executed. 40 | LastExecutedAt time.Time 41 | 42 | // Error is the error message provided by the Task processor. 43 | Error *string 44 | } 45 | 46 | // CompletedTasks contains multiple completed tasks. 47 | CompletedTasks []*Completed 48 | ) 49 | 50 | // InsertTx inserts a completed task as part of a database transaction. 51 | func (c *Completed) InsertTx(ctx context.Context, tx *sql.Tx) error { 52 | var expiresAt *int64 53 | if c.ExpiresAt != nil { 54 | v := c.ExpiresAt.UnixMilli() 55 | expiresAt = &v 56 | } 57 | 58 | _, err := tx.ExecContext( 59 | ctx, 60 | query.InsertCompletedTask, 61 | c.ID, 62 | c.CreatedAt.UnixMilli(), 63 | c.Queue, 64 | c.LastExecutedAt.UnixMilli(), 65 | c.Attempts, 66 | c.LastDuration.Microseconds(), 67 | c.Succeeded, 68 | c.Task, 69 | expiresAt, 70 | c.Error, 71 | ) 72 | return err 73 | } 74 | 75 | // GetCompletedTasks loads completed tasks from the database using a given query and arguments. 76 | func GetCompletedTasks(ctx context.Context, db *sql.DB, query string, args ...any) (CompletedTasks, error) { 77 | rows, err := db.QueryContext(ctx, query, args...) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | defer rows.Close() 83 | 84 | tasks := make(CompletedTasks, 0) 85 | 86 | for rows.Next() { 87 | var task Completed 88 | var lastExecutedAt, createdAt int64 89 | var expiresAt *int64 90 | 91 | err = rows.Scan( 92 | &task.ID, 93 | &createdAt, 94 | &task.Queue, 95 | &lastExecutedAt, 96 | &task.Attempts, 97 | &task.LastDuration, 98 | &task.Succeeded, 99 | &task.Task, 100 | &expiresAt, 101 | &task.Error, 102 | ) 103 | 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | task.LastExecutedAt = time.UnixMilli(lastExecutedAt) 109 | task.CreatedAt = time.UnixMilli(createdAt) 110 | task.LastDuration *= 1000 111 | 112 | if expiresAt != nil { 113 | v := time.UnixMilli(*expiresAt) 114 | task.ExpiresAt = &v 115 | } 116 | 117 | tasks = append(tasks, &task) 118 | } 119 | 120 | if err = rows.Err(); err != nil { 121 | return nil, err 122 | } 123 | 124 | return tasks, nil 125 | } 126 | 127 | // DeleteExpiredCompleted deletes completed tasks that have an expiration date in the past. 128 | func DeleteExpiredCompleted(ctx context.Context, db *sql.DB) error { 129 | _, err := db.ExecContext( 130 | ctx, 131 | query.DeleteExpiredCompletedTasks, 132 | time.Now().UnixMilli(), 133 | ) 134 | return err 135 | } 136 | -------------------------------------------------------------------------------- /internal/task/task.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | "github.com/mikestefanello/backlite/internal/query" 11 | ) 12 | 13 | // Task is a task that is queued for execution. 14 | type Task struct { 15 | // ID is the Task ID. 16 | ID string 17 | 18 | // Queue is the name of the queue this Task belongs to. 19 | Queue string 20 | 21 | // Task is the task data. 22 | Task []byte 23 | 24 | // Attempts are the amount of times this Task was executed. 25 | Attempts int 26 | 27 | // WaitUntil is the time the task should not be executed until. 28 | WaitUntil *time.Time 29 | 30 | // CreatedAt is when the Task was originally created. 31 | CreatedAt time.Time 32 | 33 | // LastExecutedAt is the last time this Task executed. 34 | LastExecutedAt *time.Time 35 | 36 | // ClaimedAt is the time this Task was claimed for execution. 37 | ClaimedAt *time.Time 38 | } 39 | 40 | // InsertTx inserts a task as part of a database transaction. 41 | func (t *Task) InsertTx(ctx context.Context, tx *sql.Tx) error { 42 | if len(t.ID) == 0 { 43 | // UUID is used because it's faster and more reliable than having the DB generate a random string. 44 | // And since it's time-sortable, we avoid needing a separate index on the created time. 45 | id, err := uuid.NewV7() 46 | if err != nil { 47 | return fmt.Errorf("unable to generate task ID: %w", err) 48 | } 49 | t.ID = id.String() 50 | } 51 | 52 | if t.CreatedAt.IsZero() { 53 | t.CreatedAt = time.Now() 54 | } 55 | 56 | var wait *int64 57 | if t.WaitUntil != nil { 58 | v := t.WaitUntil.UnixMilli() 59 | wait = &v 60 | } 61 | 62 | _, err := tx.ExecContext( 63 | ctx, 64 | query.InsertTask, 65 | t.ID, 66 | t.CreatedAt.UnixMilli(), 67 | t.Queue, 68 | t.Task, 69 | wait, 70 | ) 71 | 72 | return err 73 | } 74 | 75 | // DeleteTx deletes a task as part of a database transaction. 76 | func (t *Task) DeleteTx(ctx context.Context, tx *sql.Tx) error { 77 | _, err := tx.ExecContext(ctx, query.DeleteTask, t.ID) 78 | return err 79 | } 80 | 81 | // Fail marks a task as failed in the database and queues it to be executed again. 82 | func (t *Task) Fail(ctx context.Context, db *sql.DB, waitUntil time.Time) error { 83 | _, err := db.ExecContext( 84 | ctx, 85 | query.TaskFailed, 86 | waitUntil.UnixMilli(), 87 | t.LastExecutedAt.UnixMilli(), 88 | t.ID, 89 | ) 90 | return err 91 | } 92 | -------------------------------------------------------------------------------- /internal/task/tasks.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | 8 | "github.com/mikestefanello/backlite/internal/query" 9 | ) 10 | 11 | // Tasks are a slice of tasks. 12 | type Tasks []*Task 13 | 14 | // Claim updates a Task in the database to indicate that it has been claimed by a processor to be executed. 15 | func (t Tasks) Claim(ctx context.Context, db *sql.DB) error { 16 | if len(t) == 0 { 17 | return nil 18 | } 19 | 20 | params := make([]any, 0, len(t)+1) 21 | params = append(params, time.Now().UnixMilli()) 22 | 23 | for _, task := range t { 24 | params = append(params, task.ID) 25 | } 26 | 27 | _, err := db.ExecContext( 28 | ctx, 29 | query.ClaimTasks(len(t)), 30 | params..., 31 | ) 32 | 33 | return err 34 | } 35 | 36 | // GetTasks loads tasks from the database using a given query and arguments. 37 | func GetTasks(ctx context.Context, db *sql.DB, query string, args ...any) (Tasks, error) { 38 | rows, err := db.QueryContext(ctx, query, args...) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | defer rows.Close() 44 | 45 | tasks := make(Tasks, 0) 46 | 47 | toTime := func(ms *int64) *time.Time { 48 | if ms == nil { 49 | return nil 50 | } 51 | v := time.UnixMilli(*ms) 52 | return &v 53 | } 54 | 55 | for rows.Next() { 56 | var task Task 57 | var createdAt int64 58 | var waitUntil, lastExecutedAt, claimedAt *int64 59 | 60 | err = rows.Scan( 61 | &task.ID, 62 | &task.Queue, 63 | &task.Task, 64 | &task.Attempts, 65 | &waitUntil, 66 | &createdAt, 67 | &lastExecutedAt, 68 | &claimedAt, 69 | ) 70 | 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | task.CreatedAt = time.UnixMilli(createdAt) 76 | task.WaitUntil = toTime(waitUntil) 77 | task.LastExecutedAt = toTime(lastExecutedAt) 78 | task.ClaimedAt = toTime(claimedAt) 79 | 80 | tasks = append(tasks, &task) 81 | } 82 | 83 | if err = rows.Err(); err != nil { 84 | return nil, err 85 | } 86 | 87 | return tasks, nil 88 | } 89 | 90 | // GetScheduledTasks loads the tasks that are next up to be executed in order of execution time. 91 | // It's important to note that this does not filter out tasks that are not yet ready based on their wait time. 92 | // The deadline provided is used to include tasks that have been claimed if that given amount of time has elapsed. 93 | func GetScheduledTasks(ctx context.Context, db *sql.DB, deadline time.Time, limit int) (Tasks, error) { 94 | return GetScheduledTasksWithOffset( 95 | ctx, 96 | db, 97 | deadline, 98 | limit, 99 | 0, 100 | ) 101 | } 102 | 103 | // GetScheduledTasksWithOffset is the same as GetScheduledTasks but with an offset for paging. 104 | func GetScheduledTasksWithOffset( 105 | ctx context.Context, 106 | db *sql.DB, 107 | deadline time.Time, 108 | limit, 109 | offset int) (Tasks, error) { 110 | return GetTasks( 111 | ctx, 112 | db, 113 | query.SelectScheduledTasks, 114 | deadline.UnixMilli(), 115 | limit, 116 | offset, 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /internal/testutil/testutil.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "database/sql" 7 | "encoding/json" 8 | "fmt" 9 | "testing" 10 | "time" 11 | 12 | "github.com/google/uuid" 13 | _ "github.com/mattn/go-sqlite3" 14 | 15 | "github.com/mikestefanello/backlite/internal/query" 16 | "github.com/mikestefanello/backlite/internal/task" 17 | ) 18 | 19 | func GetTasks(t *testing.T, db *sql.DB) task.Tasks { 20 | got, err := task.GetTasks(context.Background(), db, ` 21 | SELECT 22 | id, queue, task, attempts, wait_until, created_at, last_executed_at, claimed_at 23 | FROM 24 | backlite_tasks 25 | ORDER BY 26 | id ASC 27 | `) 28 | 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | return got 34 | } 35 | 36 | func InsertTask(t *testing.T, db *sql.DB, tk *task.Task) { 37 | tx, err := db.Begin() 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | if err := tk.InsertTx(context.Background(), tx); err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | if err := tx.Commit(); err != nil { 47 | t.Fatal(err) 48 | } 49 | } 50 | 51 | func TaskIDsExist(t *testing.T, db *sql.DB, ids []string) { 52 | idMap := make(map[string]struct{}, len(ids)) 53 | for _, id := range ids { 54 | idMap[id] = struct{}{} 55 | } 56 | for _, tc := range GetTasks(t, db) { 57 | delete(idMap, tc.ID) 58 | } 59 | 60 | if len(idMap) != 0 { 61 | t.Errorf("ids do not exist: %v", idMap) 62 | } 63 | } 64 | 65 | func DeleteTasks(t *testing.T, db *sql.DB) { 66 | _, err := db.Exec("DELETE FROM backlite_tasks") 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | } 71 | 72 | func GetCompletedTasks(t *testing.T, db *sql.DB) task.CompletedTasks { 73 | got, err := task.GetCompletedTasks(context.Background(), db, ` 74 | SELECT 75 | * 76 | FROM 77 | backlite_tasks_completed 78 | ORDER BY 79 | id ASC 80 | `) 81 | 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | return got 87 | } 88 | 89 | func DeleteCompletedTasks(t *testing.T, db *sql.DB) { 90 | _, err := db.Exec("DELETE FROM backlite_tasks_completed") 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | } 95 | 96 | func InsertCompleted(t *testing.T, db *sql.DB, completed task.Completed) { 97 | tx, err := db.Begin() 98 | if err != nil { 99 | t.Fatal(err) 100 | } 101 | 102 | if err := completed.InsertTx(context.Background(), tx); err != nil { 103 | t.Fatal(err) 104 | } 105 | 106 | if err := tx.Commit(); err != nil { 107 | t.Fatal(err) 108 | } 109 | } 110 | 111 | func CompleteTaskIDsExist(t *testing.T, db *sql.DB, ids []string) { 112 | idMap := make(map[string]struct{}, len(ids)) 113 | for _, id := range ids { 114 | idMap[id] = struct{}{} 115 | } 116 | for _, tc := range GetCompletedTasks(t, db) { 117 | delete(idMap, tc.ID) 118 | } 119 | 120 | if len(idMap) != 0 { 121 | t.Errorf("ids do not exist: %v", idMap) 122 | } 123 | } 124 | 125 | func Equal[T comparable](t *testing.T, name string, expected, got T) { 126 | if expected != got { 127 | t.Errorf("%s; expected %v, got %v", name, expected, got) 128 | } 129 | } 130 | 131 | func Length[T any](t *testing.T, obj []T, expectedLength int) { 132 | if len(obj) != expectedLength { 133 | t.Errorf("expected %d items, got %d", expectedLength, len(obj)) 134 | } 135 | } 136 | 137 | func IsTask(t *testing.T, expected, got task.Task) { 138 | Equal(t, "Queue", expected.Queue, got.Queue) 139 | Equal(t, "Attempts", expected.Attempts, got.Attempts) 140 | Equal(t, "CreatedAt", expected.CreatedAt, got.CreatedAt) 141 | 142 | if !bytes.Equal(expected.Task, got.Task) { 143 | t.Error("Task bytes not equal") 144 | } 145 | 146 | switch { 147 | case expected.WaitUntil == nil && got.WaitUntil == nil: 148 | case expected.WaitUntil != nil && got.WaitUntil != nil: 149 | Equal(t, "WaitUntil", *expected.WaitUntil, *got.WaitUntil) 150 | default: 151 | t.Error("WaitUntil not equal") 152 | } 153 | 154 | switch { 155 | case expected.LastExecutedAt == nil && got.LastExecutedAt == nil: 156 | case expected.LastExecutedAt != nil && got.LastExecutedAt != nil: 157 | Equal(t, "LastExecutedAt", *expected.LastExecutedAt, *got.LastExecutedAt) 158 | default: 159 | t.Error("LastExecutedAt not equal") 160 | } 161 | 162 | switch { 163 | case expected.ClaimedAt == nil && got.ClaimedAt == nil: 164 | case expected.ClaimedAt != nil && got.ClaimedAt != nil: 165 | Equal(t, "ClaimedAt", *expected.ClaimedAt, *got.ClaimedAt) 166 | default: 167 | t.Error("ClaimedAt not equal") 168 | } 169 | } 170 | 171 | func Encode(t *testing.T, v any) []byte { 172 | b := bytes.NewBuffer(nil) 173 | err := json.NewEncoder(b).Encode(v) 174 | if err != nil { 175 | t.Fatal(err) 176 | } 177 | return b.Bytes() 178 | } 179 | 180 | func Pointer[T any](v T) *T { 181 | return &v 182 | } 183 | 184 | func Wait() { 185 | time.Sleep(100 * time.Millisecond) 186 | } 187 | 188 | func NewDB(t *testing.T) *sql.DB { 189 | db, err := sql.Open("sqlite3", fmt.Sprintf("file:/%s?vfs=memdb&_timeout=1000", uuid.New().String())) 190 | if err != nil { 191 | t.Fatal(err) 192 | } 193 | 194 | _, err = db.Exec(query.Schema) 195 | if err != nil { 196 | t.Fatal(err) 197 | } 198 | return db 199 | } 200 | 201 | func WaitForChan[T any](t *testing.T, signal chan T) { 202 | select { 203 | case <-signal: 204 | case <-time.After(500 * time.Millisecond): 205 | t.Error("signal not received") 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /internal/testutil/testutil_test.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mikestefanello/backlite/internal/task" 11 | ) 12 | 13 | func TestDBFail(t *testing.T) { 14 | db, err := sql.Open("sqlite3", ":memory:") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | test := func(name string, tester func(t *testing.T)) { 20 | wg := sync.WaitGroup{} 21 | wg.Add(1) 22 | go func() { 23 | defer wg.Done() 24 | st := &testing.T{} 25 | tester(st) 26 | 27 | if !st.Failed() { 28 | t.Fatalf("expected %s to fail", name) 29 | } 30 | }() 31 | wg.Wait() 32 | } 33 | 34 | test("GetTasks", func(t *testing.T) { 35 | GetTasks(t, db) 36 | }) 37 | 38 | test("InsertTask", func(t *testing.T) { 39 | InsertTask(t, db, &task.Task{}) 40 | }) 41 | 42 | test("DeleteTasks", func(t *testing.T) { 43 | DeleteTasks(t, db) 44 | }) 45 | 46 | test("GetCompletedTasks", func(t *testing.T) { 47 | GetCompletedTasks(t, db) 48 | }) 49 | 50 | test("DeleteCompletedTasks", func(t *testing.T) { 51 | DeleteCompletedTasks(t, db) 52 | }) 53 | 54 | test("InsertCompleted", func(t *testing.T) { 55 | InsertCompleted(t, db, task.Completed{}) 56 | }) 57 | } 58 | 59 | func TestWaitForChan(t *testing.T) { 60 | c := make(chan int, 1) 61 | st := &testing.T{} 62 | c <- 1 63 | WaitForChan(st, c) 64 | if st.Failed() { 65 | t.Fatalf("should not have failed") 66 | } 67 | 68 | WaitForChan(st, c) 69 | if !st.Failed() { 70 | t.Fatalf("should have failed") 71 | } 72 | } 73 | 74 | func TestLength(t *testing.T) { 75 | st := &testing.T{} 76 | obj := []int{1, 2} 77 | Length(st, obj, 2) 78 | if st.Failed() { 79 | t.Error("should not have failed") 80 | } 81 | Length(st, obj, 1) 82 | if !st.Failed() { 83 | t.Error("should have failed") 84 | } 85 | } 86 | 87 | func TestEqual(t *testing.T) { 88 | st := &testing.T{} 89 | Equal(st, "t", "a", "a") 90 | if st.Failed() { 91 | t.Error("should not have failed") 92 | } 93 | Equal(st, "t", "a", "b") 94 | if !st.Failed() { 95 | t.Error("should have failed") 96 | } 97 | } 98 | 99 | func TestCompleteTaskIDsExist(t *testing.T) { 100 | st := &testing.T{} 101 | db := NewDB(t) 102 | InsertCompleted(t, db, task.Completed{ID: "1"}) 103 | InsertCompleted(t, db, task.Completed{ID: "2"}) 104 | CompleteTaskIDsExist(st, db, []string{"1", "2"}) 105 | if st.Failed() { 106 | t.Error("should not have failed") 107 | } 108 | CompleteTaskIDsExist(st, db, []string{"1", "2", "3"}) 109 | if !st.Failed() { 110 | t.Error("should have failed") 111 | } 112 | } 113 | 114 | func TestTaskIDsExist(t *testing.T) { 115 | st := &testing.T{} 116 | db := NewDB(t) 117 | InsertTask(t, db, &task.Task{ID: "1", Task: []byte("a")}) 118 | InsertTask(t, db, &task.Task{ID: "2", Task: []byte("a")}) 119 | TaskIDsExist(st, db, []string{"1", "2"}) 120 | if st.Failed() { 121 | t.Error("should not have failed") 122 | } 123 | TaskIDsExist(st, db, []string{"1", "2", "3"}) 124 | if !st.Failed() { 125 | t.Error("should have failed") 126 | } 127 | } 128 | 129 | func TestIsTask(t *testing.T) { 130 | var a, b task.Task 131 | 132 | check := func(expectFail bool) { 133 | st := &testing.T{} 134 | IsTask(st, a, b) 135 | if expectFail && !st.Failed() { 136 | t.Error("should have failed") 137 | } 138 | if !expectFail && st.Failed() { 139 | t.Error("should not have failed") 140 | } 141 | b = a 142 | } 143 | 144 | a = task.Task{ 145 | ID: "1", 146 | Queue: "a", 147 | Task: []byte{1, 2, 3}, 148 | Attempts: 1, 149 | WaitUntil: Pointer(time.Now()), 150 | CreatedAt: time.Now(), 151 | LastExecutedAt: Pointer(time.Now()), 152 | ClaimedAt: Pointer(time.Now()), 153 | } 154 | b = a 155 | check(false) 156 | b.Task = []byte{1, 2} 157 | check(true) 158 | b.Queue = "b" 159 | check(true) 160 | b.WaitUntil = nil 161 | check(true) 162 | b.LastExecutedAt = nil 163 | check(true) 164 | b.ClaimedAt = nil 165 | check(true) 166 | b.WaitUntil = Pointer(time.Now().Add(time.Minute)) 167 | check(true) 168 | b.ClaimedAt = Pointer(time.Now().Add(time.Minute)) 169 | check(true) 170 | b.LastExecutedAt = Pointer(time.Now().Add(time.Minute)) 171 | check(true) 172 | a.WaitUntil = nil 173 | b.WaitUntil = nil 174 | check(false) 175 | a.LastExecutedAt = nil 176 | b.LastExecutedAt = nil 177 | check(false) 178 | a.ClaimedAt = nil 179 | b.ClaimedAt = nil 180 | check(false) 181 | } 182 | 183 | func TestEncode(t *testing.T) { 184 | st := &testing.T{} 185 | type Test struct { 186 | Val string 187 | } 188 | b := Encode(st, Test{Val: "test"}) 189 | if st.Failed() { 190 | t.Error("should not have failed") 191 | } 192 | if !bytes.Equal(bytes.TrimSpace(b), []byte(`{"Val":"test"}`)) { 193 | t.Error("should be equal") 194 | } 195 | 196 | type Test2 struct { 197 | Val chan int 198 | } 199 | 200 | wg := sync.WaitGroup{} 201 | wg.Add(1) 202 | go func() { 203 | defer wg.Done() 204 | _ = Encode(st, Test2{Val: make(chan int)}) 205 | if !st.Failed() { 206 | t.Error("should have failed") 207 | } 208 | }() 209 | wg.Wait() 210 | } 211 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | type ( 4 | // Logger is used to log operations. 5 | Logger interface { 6 | // Info logs info messages. 7 | Info(message string, params ...any) 8 | 9 | // Error logs error messages. 10 | Error(message string, params ...any) 11 | } 12 | 13 | // noLogger is the default logger and will log nothing. 14 | noLogger struct{} 15 | ) 16 | 17 | func (n noLogger) Info(message string, params ...any) {} 18 | func (n noLogger) Error(message string, params ...any) {} 19 | -------------------------------------------------------------------------------- /queue.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | type ( 13 | // Queue represents a queue which contains tasks to be executed. 14 | Queue interface { 15 | // Config returns the configuration for the queue. 16 | Config() *QueueConfig 17 | 18 | // Process processes the Task. 19 | Process(ctx context.Context, payload []byte) error 20 | } 21 | 22 | // QueueConfig is the configuration options for a queue. 23 | QueueConfig struct { 24 | // Name is the name of the queue and must be unique. 25 | Name string 26 | 27 | // MaxAttempts are the maximum number of attempts to execute this task before it's marked as completed. 28 | MaxAttempts int 29 | 30 | // Timeout is the duration set on the context while executing a given task. 31 | Timeout time.Duration 32 | 33 | // Backoff is the duration a failed task will be held in the queue until being retried. 34 | Backoff time.Duration 35 | 36 | // Retention dictates if and how completed tasks will be retained in the database. 37 | // If nil, no completed tasks will be retained. 38 | Retention *Retention 39 | } 40 | 41 | // Retention is the policy for how completed tasks will be retained in the database. 42 | Retention struct { 43 | // Duration is the amount of time to retain a task for after completion. 44 | // If omitted, the task will be retained forever. 45 | Duration time.Duration 46 | 47 | // OnlyFailed indicates if only failed tasks should be retained. 48 | OnlyFailed bool 49 | 50 | // Data provides options for retaining Task payload data. 51 | // If nil, no task payload data will be retained. 52 | Data *RetainData 53 | } 54 | 55 | // RetainData is the policy for how Task payload data will be retained in the database after the task is complete. 56 | RetainData struct { 57 | // OnlyFailed indicates if Task payload data should only be retained for failed tasks. 58 | OnlyFailed bool 59 | } 60 | 61 | // queue provides a type-safe implementation of Queue 62 | queue[T Task] struct { 63 | config *QueueConfig 64 | processor QueueProcessor[T] 65 | } 66 | 67 | // QueueProcessor is a generic processor callback for a given queue to process Tasks 68 | QueueProcessor[T Task] func(context.Context, T) error 69 | 70 | // queues stores a registry of queues. 71 | queues struct { 72 | registry map[string]Queue 73 | sync.RWMutex 74 | } 75 | ) 76 | 77 | // NewQueue creates a new type-safe Queue of a given Task type 78 | func NewQueue[T Task](processor QueueProcessor[T]) Queue { 79 | var task T 80 | cfg := task.Config() 81 | 82 | q := &queue[T]{ 83 | config: &cfg, 84 | processor: processor, 85 | } 86 | 87 | return q 88 | } 89 | 90 | func (q *queue[T]) Config() *QueueConfig { 91 | return q.config 92 | } 93 | 94 | func (q *queue[T]) Process(ctx context.Context, payload []byte) error { 95 | var obj T 96 | 97 | err := json. 98 | NewDecoder(bytes.NewReader(payload)). 99 | Decode(&obj) 100 | 101 | if err != nil { 102 | return err 103 | } 104 | 105 | return q.processor(ctx, obj) 106 | } 107 | 108 | // add adds a queue to the registry and will panic if the name has already been registered. 109 | func (q *queues) add(queue Queue) { 110 | if len(queue.Config().Name) == 0 { 111 | panic("queue name is missing") 112 | } 113 | 114 | q.Lock() 115 | defer q.Unlock() 116 | 117 | if _, exists := q.registry[queue.Config().Name]; exists { 118 | panic(fmt.Sprintf("queue '%s' already registered", queue.Config().Name)) 119 | } 120 | 121 | q.registry[queue.Config().Name] = queue 122 | } 123 | 124 | // get loads a queue from the registry by name. 125 | func (q *queues) get(name string) Queue { 126 | q.RLock() 127 | defer q.RUnlock() 128 | val, ok := q.registry[name] 129 | if !ok { 130 | panic(fmt.Sprintf("queue '%s' not registered, ensure all queues are registered before calling Client.Start()", name)) 131 | } 132 | return val 133 | } 134 | -------------------------------------------------------------------------------- /queue_test.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestQueue_CannotDecode(t *testing.T) { 11 | q := NewQueue[testTask](func(_ context.Context, _ testTask) error { 12 | return nil 13 | }) 14 | err := q.Process(context.Background(), []byte{1, 2, 3}) 15 | if err == nil { 16 | t.Error("Process should have failed") 17 | } 18 | } 19 | 20 | func TestQueues_GetUnregisteredQueuePanics(t *testing.T) { 21 | s := &queues{} 22 | 23 | defer func() { 24 | if r := recover(); r == nil { 25 | t.Errorf("Should be panicking, but it didn't") 26 | } else { 27 | if msg, ok := r.(string); !ok || !strings.Contains(msg, fmt.Sprintf("queue '%s' not registered", testTask{}.Config().Name)) { 28 | t.Errorf("Unexpected panic value: %v", r) 29 | } 30 | } 31 | }() 32 | 33 | s.get(testTask{}.Config().Name) 34 | } 35 | -------------------------------------------------------------------------------- /task.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | ) 8 | 9 | type ( 10 | // Task represents a task that will be placed in to a queue for execution. 11 | Task interface { 12 | // Config returns the configuration options for the queue that this Task will be placed in. 13 | Config() QueueConfig 14 | } 15 | 16 | // TaskAddOp facilitates adding Tasks to the queue. 17 | TaskAddOp struct { 18 | client *Client 19 | ctx context.Context 20 | tasks []Task 21 | wait *time.Time 22 | tx *sql.Tx 23 | } 24 | ) 25 | 26 | // Ctx sets the request context. 27 | func (t *TaskAddOp) Ctx(ctx context.Context) *TaskAddOp { 28 | t.ctx = ctx 29 | return t 30 | } 31 | 32 | // At sets the time the task should not be executed until. 33 | func (t *TaskAddOp) At(processAt time.Time) *TaskAddOp { 34 | t.wait = &processAt 35 | return t 36 | } 37 | 38 | // Wait instructs the task to wait a given duration before it is executed. 39 | func (t *TaskAddOp) Wait(duration time.Duration) *TaskAddOp { 40 | t.At(now().Add(duration)) 41 | return t 42 | } 43 | 44 | // Tx will include the task as part of a given database transaction. 45 | // When using this, it is critical that after you commit the transaction that you call Notify() on the 46 | // client so the dispatcher is aware that a new task has been created, otherwise it may not be executed. 47 | // This is necessary because there is, unfortunately, no way for outsiders to know if or when a transaction 48 | // is committed and since the dispatcher avoids continuous polling, it needs to know when tasks are added. 49 | func (t *TaskAddOp) Tx(tx *sql.Tx) *TaskAddOp { 50 | t.tx = tx 51 | return t 52 | } 53 | 54 | // Save saves the task, so it can be queued for execution. 55 | func (t *TaskAddOp) Save() error { 56 | return t.client.save(t) 57 | } 58 | -------------------------------------------------------------------------------- /task_test.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mikestefanello/backlite/internal/task" 11 | "github.com/mikestefanello/backlite/internal/testutil" 12 | ) 13 | 14 | func TestTaskAddOp_Ctx(t *testing.T) { 15 | op := &TaskAddOp{} 16 | ctx := context.Background() 17 | op.Ctx(ctx) 18 | 19 | switch { 20 | case op.ctx == nil: 21 | t.Errorf("ctx is nil") 22 | case op.ctx != ctx: 23 | t.Error("ctx wrong value") 24 | } 25 | } 26 | 27 | func TestTaskAddOp_At(t *testing.T) { 28 | op := &TaskAddOp{} 29 | at := time.Now() 30 | op.At(at) 31 | 32 | switch { 33 | case op.wait == nil: 34 | t.Error("wait is nil") 35 | case *op.wait != at: 36 | t.Error("wait wrong value") 37 | } 38 | } 39 | 40 | func TestTaskAddOp_Wait(t *testing.T) { 41 | op := &TaskAddOp{} 42 | wait := time.Hour 43 | op.Wait(wait) 44 | 45 | switch { 46 | case op.wait == nil: 47 | t.Error("wait is nil") 48 | case !op.wait.Equal(now().Add(time.Hour)): 49 | t.Error("wait wrong value") 50 | } 51 | } 52 | 53 | func TestTaskAddOp_Tx(t *testing.T) { 54 | op := &TaskAddOp{} 55 | tx := &sql.Tx{} 56 | op.Tx(tx) 57 | 58 | switch { 59 | case op.tx == nil: 60 | t.Error("tx is nil") 61 | case op.tx != tx: 62 | t.Error("tx wrong value") 63 | } 64 | } 65 | 66 | func TestTaskAddOp_Save__Single(t *testing.T) { 67 | c := mustNewClient(t) 68 | m := &mockDispatcher{} 69 | c.dispatcher = m 70 | defer c.db.Close() 71 | 72 | tk := testTask{Val: "a"} 73 | op := c.Add(tk) 74 | if err := op.Save(); err != nil { 75 | t.Fatal(err) 76 | } 77 | 78 | got := testutil.GetTasks(t, c.db) 79 | testutil.Length(t, got, 1) 80 | 81 | testutil.IsTask(t, task.Task{ 82 | Queue: tk.Config().Name, 83 | Task: testutil.Encode(t, tk), 84 | Attempts: 0, 85 | CreatedAt: now(), 86 | }, *got[0]) 87 | 88 | testutil.Equal(t, "notified", true, m.notified) 89 | } 90 | 91 | func TestTaskAddOp_Save__Wait(t *testing.T) { 92 | c := mustNewClient(t) 93 | m := &mockDispatcher{} 94 | c.dispatcher = m 95 | defer c.db.Close() 96 | 97 | tk := testTask{Val: "f"} 98 | op := c.Add(tk).Wait(time.Hour) 99 | 100 | if err := op.Save(); err != nil { 101 | t.Fatal(err) 102 | } 103 | 104 | got := testutil.GetTasks(t, c.db) 105 | testutil.Length(t, got, 1) 106 | 107 | testutil.IsTask(t, task.Task{ 108 | Queue: tk.Config().Name, 109 | Task: testutil.Encode(t, tk), 110 | Attempts: 0, 111 | CreatedAt: now(), 112 | WaitUntil: testutil.Pointer(now().Add(time.Hour)), 113 | }, *got[0]) 114 | } 115 | 116 | func TestTaskAddOp_Save__Multiple(t *testing.T) { 117 | c := mustNewClient(t) 118 | m := &mockDispatcher{} 119 | c.dispatcher = m 120 | defer c.db.Close() 121 | 122 | task1 := testTask{Val: "b"} 123 | task2 := testTask{Val: "c"} 124 | op := c.Add(task1, task2) 125 | if err := op.Save(); err != nil { 126 | t.Fatal(err) 127 | } 128 | 129 | got := testutil.GetTasks(t, c.db) 130 | testutil.Length(t, got, 2) 131 | 132 | testutil.IsTask(t, task.Task{ 133 | Queue: task1.Config().Name, 134 | Task: testutil.Encode(t, task1), 135 | Attempts: 0, 136 | CreatedAt: now(), 137 | }, *got[0]) 138 | 139 | testutil.IsTask(t, task.Task{ 140 | Queue: task2.Config().Name, 141 | Task: testutil.Encode(t, task2), 142 | Attempts: 0, 143 | CreatedAt: now(), 144 | }, *got[1]) 145 | } 146 | 147 | func TestTaskAddOp_Save__Context(t *testing.T) { 148 | c := mustNewClient(t) 149 | m := &mockDispatcher{} 150 | c.dispatcher = m 151 | defer c.db.Close() 152 | 153 | ctx, cancel := context.WithCancel(context.Background()) 154 | tk := testTask{Val: "d"} 155 | op := c.Add(tk).Ctx(ctx) 156 | cancel() 157 | 158 | if err := op.Save(); !errors.Is(err, context.Canceled) { 159 | t.Error("expected context cancel") 160 | } 161 | } 162 | 163 | func TestTaskAddOp_Save__Transaction(t *testing.T) { 164 | c := mustNewClient(t) 165 | m := &mockDispatcher{} 166 | c.dispatcher = m 167 | defer c.db.Close() 168 | 169 | tx, err := c.db.Begin() 170 | if err != nil { 171 | t.Fatal(err) 172 | } 173 | 174 | if err := c.Install(); err != nil { 175 | t.Fatal(err) 176 | } 177 | 178 | tk := testTask{Val: "e"} 179 | op := c.Add(tk).Tx(tx) 180 | 181 | if err = op.Save(); err != nil { 182 | t.Fatal(err) 183 | } 184 | 185 | testutil.Equal(t, "notified", false, m.notified) 186 | 187 | if err = tx.Commit(); err != nil { 188 | t.Fatal(err) 189 | } 190 | 191 | got := testutil.GetTasks(t, c.db) 192 | testutil.Length(t, got, 1) 193 | } 194 | 195 | func TestTaskAddOp_Save__EncodeFailure(t *testing.T) { 196 | c := mustNewClient(t) 197 | m := &mockDispatcher{} 198 | c.dispatcher = m 199 | defer c.db.Close() 200 | 201 | tx, err := c.db.Begin() 202 | if err != nil { 203 | t.Fatal(err) 204 | } 205 | 206 | if err := c.Install(); err != nil { 207 | t.Fatal(err) 208 | } 209 | 210 | tk := testTaskEncodeFail{Val: make(chan int)} 211 | op := c.Add(tk).Tx(tx) 212 | 213 | if err = op.Save(); err == nil { 214 | t.Error("expected error") 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /task_types_test.go: -------------------------------------------------------------------------------- 1 | package backlite 2 | 3 | import "time" 4 | 5 | type testTask struct { 6 | Val string 7 | } 8 | 9 | func (t testTask) Config() QueueConfig { 10 | return QueueConfig{ 11 | Name: "test", 12 | MaxAttempts: 2, 13 | Backoff: 5 * time.Millisecond, 14 | Timeout: time.Second, 15 | Retention: &Retention{ 16 | Duration: time.Hour, 17 | OnlyFailed: false, 18 | Data: &RetainData{ 19 | OnlyFailed: false, 20 | }, 21 | }, 22 | } 23 | } 24 | 25 | type testTaskRentainForever struct { 26 | Val string 27 | } 28 | 29 | func (t testTaskRentainForever) Config() QueueConfig { 30 | return QueueConfig{ 31 | Name: "test-retainforever", 32 | MaxAttempts: 2, 33 | Backoff: 8 * time.Millisecond, 34 | Timeout: 2 * time.Second, 35 | Retention: &Retention{ 36 | OnlyFailed: false, 37 | }, 38 | } 39 | } 40 | 41 | type testTaskNoRention struct { 42 | Val string 43 | } 44 | 45 | func (t testTaskNoRention) Config() QueueConfig { 46 | return QueueConfig{ 47 | Name: "test-noret", 48 | MaxAttempts: 2, 49 | Backoff: 8 * time.Millisecond, 50 | Timeout: 2 * time.Second, 51 | Retention: nil, 52 | } 53 | } 54 | 55 | type testTaskRetainFailed struct { 56 | Val string 57 | } 58 | 59 | func (t testTaskRetainFailed) Config() QueueConfig { 60 | return QueueConfig{ 61 | Name: "test-retainfailed", 62 | MaxAttempts: 2, 63 | Backoff: 8 * time.Millisecond, 64 | Timeout: 2 * time.Second, 65 | Retention: &Retention{ 66 | OnlyFailed: true, 67 | }, 68 | } 69 | } 70 | 71 | type testTaskRetainNoData struct { 72 | Val string 73 | } 74 | 75 | func (t testTaskRetainNoData) Config() QueueConfig { 76 | return QueueConfig{ 77 | Name: "test-retainnodata", 78 | MaxAttempts: 2, 79 | Backoff: 5 * time.Millisecond, 80 | Timeout: time.Second, 81 | Retention: &Retention{ 82 | Duration: time.Hour, 83 | OnlyFailed: false, 84 | Data: nil, 85 | }, 86 | } 87 | } 88 | 89 | type testTaskRetainDataFailed struct { 90 | Val string 91 | } 92 | 93 | func (t testTaskRetainDataFailed) Config() QueueConfig { 94 | return QueueConfig{ 95 | Name: "test-retaindatafailed", 96 | MaxAttempts: 2, 97 | Backoff: 5 * time.Millisecond, 98 | Timeout: time.Second, 99 | Retention: &Retention{ 100 | Duration: time.Hour, 101 | OnlyFailed: false, 102 | Data: &RetainData{ 103 | OnlyFailed: true, 104 | }, 105 | }, 106 | } 107 | } 108 | 109 | type testTaskNoName struct { 110 | Val string 111 | } 112 | 113 | func (t testTaskNoName) Config() QueueConfig { 114 | return QueueConfig{ 115 | Name: "", 116 | } 117 | } 118 | 119 | type testTaskEncodeFail struct { 120 | Val chan int 121 | } 122 | 123 | func (t testTaskEncodeFail) Config() QueueConfig { 124 | return QueueConfig{ 125 | Name: "encode-failer", 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # Web UI 2 | 3 | Easily monitor your queues via the provided web UI. 4 | 5 | * [Instructions](https://github.com/mikestefanello/backlite?tab=readme-ov-file#web-ui) 6 | * [Screenshots](https://github.com/mikestefanello/backlite?tab=readme-ov-file#screenshots) -------------------------------------------------------------------------------- /ui/handler.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "strconv" 12 | "strings" 13 | "text/template" 14 | "time" 15 | 16 | "github.com/mikestefanello/backlite/internal/task" 17 | ) 18 | 19 | type ( 20 | // Handler handles HTTP requests for the Backlite UI. 21 | Handler struct { 22 | // cfg stores the UI configuration. 23 | cfg Config 24 | } 25 | 26 | // Config contains configuration for the Handler. 27 | Config struct { 28 | // DB is the Backlite database. 29 | DB *sql.DB 30 | 31 | // BasePath is an optional base path to prepend to all URL paths. 32 | BasePath string 33 | 34 | // ItemsPerPage is the maximum amount of items to display per page. This defaults to 25. 35 | ItemsPerPage int 36 | 37 | // ReleaseAfter is the duration after which a task is released back to a queue if it has not finished executing. 38 | // Ths defaults to one hour, but it should match the value you use in your Backlite client. 39 | ReleaseAfter time.Duration 40 | } 41 | 42 | // templateData is a wrapper of data sent to templates for rendering. 43 | templateData struct { 44 | // Path is the current request URL path, excluding the base path. 45 | Path string 46 | 47 | // BasePath is the configured base path. 48 | BasePath string 49 | 50 | // Content is the data to render. 51 | Content any 52 | 53 | // Page is the page number. 54 | Page int 55 | } 56 | 57 | // handleFunc is an HTTP handle func that returns an error. 58 | handleFunc func(http.ResponseWriter, *http.Request) error 59 | ) 60 | 61 | // NewHandler creates a new handler for the Backlite web UI. 62 | func NewHandler(config Config) (*Handler, error) { 63 | if config.ReleaseAfter == 0 { 64 | config.ReleaseAfter = time.Hour 65 | } 66 | 67 | if config.ItemsPerPage == 0 { 68 | config.ItemsPerPage = 25 69 | } 70 | 71 | if config.BasePath != "" { 72 | switch { 73 | case strings.HasSuffix(config.BasePath, "/"): 74 | return nil, errors.New("base path must not end with /") 75 | case !strings.HasPrefix(config.BasePath, "/"): 76 | return nil, errors.New("base path must start with /") 77 | } 78 | } 79 | 80 | switch { 81 | case config.DB == nil: 82 | return nil, errors.New("db is required") 83 | case config.ItemsPerPage <= 0: 84 | return nil, errors.New("items per page must be greater than zero") 85 | case config.ReleaseAfter < 0: 86 | return nil, errors.New("release after must be greater than zero") 87 | } 88 | 89 | return &Handler{cfg: config}, nil 90 | } 91 | 92 | // Register registers all available routes. 93 | func (h *Handler) Register(mux *http.ServeMux) *http.ServeMux { 94 | path := func(p string) string { 95 | if h.cfg.BasePath != "" && p == "/" { 96 | p = "" 97 | } 98 | return fmt.Sprintf("GET %s%s", h.cfg.BasePath, p) 99 | } 100 | mux.HandleFunc(path("/"), handle(h.Running)) 101 | mux.HandleFunc(path("/upcoming"), handle(h.Upcoming)) 102 | mux.HandleFunc(path("/succeeded"), handle(h.Succeeded)) 103 | mux.HandleFunc(path("/failed"), handle(h.Failed)) 104 | mux.HandleFunc(path("/task/{task}"), handle(h.Task)) 105 | mux.HandleFunc(path("/completed/{task}"), handle(h.TaskCompleted)) 106 | return mux 107 | } 108 | 109 | func handle(hf handleFunc) http.HandlerFunc { 110 | return func(w http.ResponseWriter, r *http.Request) { 111 | if err := hf(w, r); err != nil { 112 | w.WriteHeader(http.StatusInternalServerError) 113 | fmt.Fprint(w, err) 114 | log.Println(err) 115 | } 116 | } 117 | } 118 | 119 | // Running renders the running tasks. 120 | func (h *Handler) Running(w http.ResponseWriter, req *http.Request) error { 121 | tasks, err := task.GetTasks( 122 | req.Context(), 123 | h.cfg.DB, 124 | selectRunningTasks, 125 | h.cfg.ItemsPerPage, 126 | h.getOffset(req.URL), 127 | ) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | return h.render(req, w, tmplTasksRunning, tasks) 133 | } 134 | 135 | // Upcoming renders the upcoming tasks. 136 | func (h *Handler) Upcoming(w http.ResponseWriter, req *http.Request) error { 137 | tasks, err := task.GetScheduledTasksWithOffset( 138 | req.Context(), 139 | h.cfg.DB, 140 | time.Now().Add(-h.cfg.ReleaseAfter), 141 | h.cfg.ItemsPerPage, 142 | h.getOffset(req.URL), 143 | ) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | return h.render(req, w, tmplTasksUpcoming, tasks) 149 | } 150 | 151 | // Succeeded renders the completed tasks that have succeeded. 152 | func (h *Handler) Succeeded(w http.ResponseWriter, req *http.Request) error { 153 | tasks, err := task.GetCompletedTasks( 154 | req.Context(), 155 | h.cfg.DB, 156 | selectCompletedTasks, 157 | 1, 158 | h.cfg.ItemsPerPage, 159 | h.getOffset(req.URL), 160 | ) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | return h.render(req, w, tmplTasksCompleted, tasks) 166 | } 167 | 168 | // Failed renders the completed tasks that have failed. 169 | func (h *Handler) Failed(w http.ResponseWriter, req *http.Request) error { 170 | tasks, err := task.GetCompletedTasks( 171 | req.Context(), 172 | h.cfg.DB, 173 | selectCompletedTasks, 174 | 0, 175 | h.cfg.ItemsPerPage, 176 | h.getOffset(req.URL), 177 | ) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | return h.render(req, w, tmplTasksCompleted, tasks) 183 | } 184 | 185 | // Task renders a task. 186 | func (h *Handler) Task(w http.ResponseWriter, req *http.Request) error { 187 | id := req.PathValue("task") 188 | tasks, err := task.GetTasks(req.Context(), h.cfg.DB, selectTask, id) 189 | if err != nil { 190 | return err 191 | } 192 | 193 | if len(tasks) > 0 { 194 | return h.render(req, w, tmplTask, tasks[0]) 195 | } 196 | 197 | // If no task found, try the same ID as a completed task. 198 | return h.TaskCompleted(w, req) 199 | } 200 | 201 | // TaskCompleted renders a completed task. 202 | func (h *Handler) TaskCompleted(w http.ResponseWriter, req *http.Request) error { 203 | var t *task.Completed 204 | id := req.PathValue("task") 205 | tasks, err := task.GetCompletedTasks(req.Context(), h.cfg.DB, selectCompletedTask, id) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | if len(tasks) > 0 { 211 | t = tasks[0] 212 | } 213 | 214 | return h.render(req, w, tmplTaskCompleted, t) 215 | } 216 | 217 | func (h *Handler) render(req *http.Request, w io.Writer, tmpl *template.Template, data any) error { 218 | path, _ := strings.CutPrefix(req.URL.Path, h.cfg.BasePath) 219 | if path == "" { 220 | path = "/" 221 | } 222 | return tmpl.ExecuteTemplate(w, "layout.gohtml", templateData{ 223 | Path: path, 224 | BasePath: h.cfg.BasePath, 225 | Content: data, 226 | Page: getPage(req.URL), 227 | }) 228 | } 229 | 230 | func (h *Handler) getOffset(u *url.URL) int { 231 | return (getPage(u) - 1) * h.cfg.ItemsPerPage 232 | } 233 | 234 | func getPage(u *url.URL) int { 235 | if p := u.Query().Get("page"); p != "" { 236 | if page, err := strconv.Atoi(p); err == nil { 237 | if page > 0 { 238 | return page 239 | } 240 | } 241 | } 242 | return 1 243 | } 244 | 245 | // FullPath outputs a given path in full, including the base path. 246 | func (t templateData) FullPath(path string) string { 247 | if t.BasePath != "" && path == "/" { 248 | path = "" 249 | } 250 | return fmt.Sprintf("%s%s", t.BasePath, path) 251 | } 252 | -------------------------------------------------------------------------------- /ui/handler_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "html" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/PuerkitoBio/goquery" 16 | "github.com/mikestefanello/backlite/internal/task" 17 | "github.com/mikestefanello/backlite/internal/testutil" 18 | ) 19 | 20 | func TestHandler_Validation(t *testing.T) { 21 | db := testutil.NewDB(t) 22 | 23 | t.Run("release after", func(t *testing.T) { 24 | t.Run("invalid", func(t *testing.T) { 25 | _, err := NewHandler(Config{ 26 | DB: db, 27 | ReleaseAfter: time.Second * -5, 28 | }) 29 | if err == nil { 30 | t.Fatal("expected error") 31 | } 32 | }) 33 | 34 | t.Run("default", func(t *testing.T) { 35 | h, err := NewHandler(Config{ 36 | DB: db, 37 | }) 38 | if err != nil { 39 | t.Fatal("unexpected error") 40 | } 41 | testutil.Equal(t, "ReleaseAfter", time.Hour, h.cfg.ReleaseAfter) 42 | }) 43 | }) 44 | 45 | t.Run("base path invalid", func(t *testing.T) { 46 | _, err := NewHandler(Config{ 47 | DB: db, 48 | BasePath: "abc", 49 | }) 50 | if err == nil { 51 | t.Fatal("expected error") 52 | } 53 | 54 | _, err = NewHandler(Config{ 55 | DB: db, 56 | BasePath: "abc/", 57 | }) 58 | if err == nil { 59 | t.Fatal("expected error") 60 | } 61 | }) 62 | 63 | t.Run("db missing", func(t *testing.T) { 64 | _, err := NewHandler(Config{}) 65 | if err == nil { 66 | t.Fatal("expected error") 67 | } 68 | }) 69 | 70 | t.Run("items per page", func(t *testing.T) { 71 | t.Run("invalid", func(t *testing.T) { 72 | _, err := NewHandler(Config{ 73 | DB: db, 74 | ItemsPerPage: -2, 75 | }) 76 | if err == nil { 77 | t.Fatal("expected error") 78 | } 79 | }) 80 | 81 | t.Run("default", func(t *testing.T) { 82 | h, err := NewHandler(Config{ 83 | DB: db, 84 | }) 85 | if err != nil { 86 | t.Fatal("unexpected error") 87 | } 88 | testutil.Equal(t, "ItemsPerPage", 25, h.cfg.ItemsPerPage) 89 | }) 90 | }) 91 | 92 | t.Run("valid", func(t *testing.T) { 93 | _, err := NewHandler(Config{ 94 | DB: db, 95 | ItemsPerPage: 10, 96 | BasePath: "/dashboard", 97 | ReleaseAfter: time.Minute * 5, 98 | }) 99 | if err != nil { 100 | t.Fatal("unexpected error") 101 | } 102 | }) 103 | } 104 | 105 | func TestHandler_Running(t *testing.T) { 106 | path := "/" 107 | _, doc := request(t, path) 108 | assertNav(t, doc, path) 109 | assertPager(t, doc, path) 110 | 111 | headers := doc.Find("tr th") 112 | testutil.Equal(t, "headers", 6, headers.Length()) 113 | headers.Each(func(i int, sel *goquery.Selection) { 114 | switch i { 115 | case 0, 5: 116 | testutil.Equal(t, "header", "", sel.Text()) 117 | case 1: 118 | testutil.Equal(t, "header", "Queue", sel.Text()) 119 | case 2: 120 | testutil.Equal(t, "header", "Attempt", sel.Text()) 121 | case 3: 122 | testutil.Equal(t, "header", "Created at", sel.Text()) 123 | case 4: 124 | testutil.Equal(t, "header", "Started", sel.Text()) 125 | } 126 | }) 127 | 128 | rows := doc.Find("tbody tr") 129 | testutil.Equal(t, "rows", 2, rows.Length()) 130 | rows.Each(func(rowIndex int, sel *goquery.Selection) { 131 | cells := sel.Find("td") 132 | testutil.Equal(t, "cells", 6, cells.Length()) 133 | cells.Each(func(cellIndex int, sel *goquery.Selection) { 134 | switch cellIndex { 135 | case 0: 136 | testutil.Equal(t, "cell", ``, toHTML(t, sel)) 137 | case 1: 138 | testutil.Equal(t, "cell", tasksRunning[rowIndex].Queue, sel.Text()) 139 | case 2: 140 | testutil.Equal(t, "cell", fmt.Sprint(tasksRunning[rowIndex].Attempts), sel.Text()) 141 | case 3: 142 | testutil.Equal(t, "cell", datetime(tasksRunning[rowIndex].CreatedAt), sel.Text()) 143 | case 4: 144 | testutil.Equal(t, "cell", datetime(*tasksRunning[rowIndex].ClaimedAt), sel.Text()) 145 | case 5: 146 | testutil.Equal(t, "cell", fmt.Sprintf(`View`, tasksRunning[rowIndex].ID), toHTML(t, sel)) 147 | } 148 | }) 149 | }) 150 | } 151 | 152 | func TestHandler_Upcoming(t *testing.T) { 153 | path := "/upcoming" 154 | _, doc := request(t, path) 155 | assertNav(t, doc, path) 156 | assertPager(t, doc, path) 157 | 158 | headers := doc.Find("tr th") 159 | testutil.Equal(t, "headers", 6, headers.Length()) 160 | headers.Each(func(i int, sel *goquery.Selection) { 161 | switch i { 162 | case 0, 5: 163 | testutil.Equal(t, "header", "", sel.Text()) 164 | case 1: 165 | testutil.Equal(t, "header", "Queue", sel.Text()) 166 | case 2: 167 | testutil.Equal(t, "header", "Attempts", sel.Text()) 168 | case 3: 169 | testutil.Equal(t, "header", "Created at", sel.Text()) 170 | case 4: 171 | testutil.Equal(t, "header", "Last executed at", sel.Text()) 172 | } 173 | }) 174 | 175 | rows := doc.Find("tbody tr") 176 | testutil.Equal(t, "rows", 2, rows.Length()) 177 | rows.Each(func(rowIndex int, sel *goquery.Selection) { 178 | cells := sel.Find("td") 179 | testutil.Equal(t, "cells", 6, cells.Length()) 180 | cells.Each(func(cellIndex int, sel *goquery.Selection) { 181 | switch cellIndex { 182 | case 0: 183 | testutil.Equal(t, "cell", ``, toHTML(t, sel)) 184 | case 1: 185 | testutil.Equal(t, "cell", tasksUpcoming[rowIndex].Queue, sel.Text()) 186 | case 2: 187 | testutil.Equal(t, "cell", fmt.Sprint(tasksUpcoming[rowIndex].Attempts), sel.Text()) 188 | case 3: 189 | testutil.Equal(t, "cell", datetime(tasksUpcoming[rowIndex].CreatedAt), sel.Text()) 190 | case 4: 191 | if tasksUpcoming[rowIndex].LastExecutedAt == nil { 192 | testutil.Equal(t, "cell", "Never", strings.TrimSpace(sel.Text())) 193 | } else { 194 | testutil.Equal(t, "cell", datetime(*tasksUpcoming[rowIndex].LastExecutedAt), strings.TrimSpace(sel.Text())) 195 | } 196 | case 5: 197 | testutil.Equal(t, "cell", fmt.Sprintf(`View`, tasksUpcoming[rowIndex].ID), toHTML(t, sel)) 198 | } 199 | }) 200 | }) 201 | } 202 | 203 | func TestHandler_Succeeded(t *testing.T) { 204 | path := "/succeeded" 205 | _, doc := request(t, path) 206 | assertNav(t, doc, path) 207 | assertPager(t, doc, path) 208 | 209 | headers := doc.Find("tr th") 210 | testutil.Equal(t, "headers", 7, headers.Length()) 211 | headers.Each(func(i int, sel *goquery.Selection) { 212 | switch i { 213 | case 0, 6: 214 | testutil.Equal(t, "header", "", sel.Text()) 215 | case 1: 216 | testutil.Equal(t, "header", "Queue", sel.Text()) 217 | case 2: 218 | testutil.Equal(t, "header", "Attempts", sel.Text()) 219 | case 3: 220 | testutil.Equal(t, "header", "Created at", sel.Text()) 221 | case 4: 222 | testutil.Equal(t, "header", "Last executed at", sel.Text()) 223 | case 5: 224 | testutil.Equal(t, "header", "Last duration", sel.Text()) 225 | } 226 | }) 227 | 228 | rows := doc.Find("tbody tr") 229 | testutil.Equal(t, "rows", 2, rows.Length()) 230 | rows.Each(func(rowIndex int, sel *goquery.Selection) { 231 | cells := sel.Find("td") 232 | testutil.Equal(t, "cells", 7, cells.Length()) 233 | cells.Each(func(cellIndex int, sel *goquery.Selection) { 234 | switch cellIndex { 235 | case 0: 236 | testutil.Equal(t, "cell", ``, toHTML(t, sel)) 237 | case 1: 238 | testutil.Equal(t, "cell", tasksSucceeded[rowIndex].Queue, sel.Text()) 239 | case 2: 240 | testutil.Equal(t, "cell", fmt.Sprint(tasksSucceeded[rowIndex].Attempts), sel.Text()) 241 | case 3: 242 | testutil.Equal(t, "cell", datetime(tasksSucceeded[rowIndex].CreatedAt), sel.Text()) 243 | case 4: 244 | testutil.Equal(t, "cell", datetime(tasksSucceeded[rowIndex].LastExecutedAt), strings.TrimSpace(sel.Text())) 245 | case 5: 246 | testutil.Equal(t, "cell", tasksSucceeded[rowIndex].LastDuration.String(), strings.TrimSpace(sel.Text())) 247 | case 6: 248 | testutil.Equal(t, "cell", fmt.Sprintf(`View`, tasksSucceeded[rowIndex].ID), toHTML(t, sel)) 249 | } 250 | }) 251 | }) 252 | } 253 | 254 | func TestHandler_Failed(t *testing.T) { 255 | path := "/failed" 256 | _, doc := request(t, path) 257 | assertNav(t, doc, path) 258 | assertPager(t, doc, path) 259 | 260 | headers := doc.Find("tr th") 261 | testutil.Equal(t, "headers", 7, headers.Length()) 262 | headers.Each(func(i int, sel *goquery.Selection) { 263 | switch i { 264 | case 0, 6: 265 | testutil.Equal(t, "header", "", sel.Text()) 266 | case 1: 267 | testutil.Equal(t, "header", "Queue", sel.Text()) 268 | case 2: 269 | testutil.Equal(t, "header", "Attempts", sel.Text()) 270 | case 3: 271 | testutil.Equal(t, "header", "Created at", sel.Text()) 272 | case 4: 273 | testutil.Equal(t, "header", "Last executed at", sel.Text()) 274 | case 5: 275 | testutil.Equal(t, "header", "Last duration", sel.Text()) 276 | } 277 | }) 278 | 279 | rows := doc.Find("tbody tr") 280 | testutil.Equal(t, "rows", 2, rows.Length()) 281 | rows.Each(func(rowIndex int, sel *goquery.Selection) { 282 | cells := sel.Find("td") 283 | testutil.Equal(t, "cells", 7, cells.Length()) 284 | cells.Each(func(cellIndex int, sel *goquery.Selection) { 285 | switch cellIndex { 286 | case 0: 287 | testutil.Equal(t, "cell", ``, toHTML(t, sel)) 288 | case 1: 289 | testutil.Equal(t, "cell", tasksFailed[rowIndex].Queue, sel.Text()) 290 | case 2: 291 | testutil.Equal(t, "cell", fmt.Sprint(tasksFailed[rowIndex].Attempts), sel.Text()) 292 | case 3: 293 | testutil.Equal(t, "cell", datetime(tasksFailed[rowIndex].CreatedAt), sel.Text()) 294 | case 4: 295 | testutil.Equal(t, "cell", datetime(tasksFailed[rowIndex].LastExecutedAt), strings.TrimSpace(sel.Text())) 296 | case 5: 297 | testutil.Equal(t, "cell", tasksFailed[rowIndex].LastDuration.String(), strings.TrimSpace(sel.Text())) 298 | case 6: 299 | testutil.Equal(t, "cell", fmt.Sprintf(`View`, tasksFailed[rowIndex].ID), toHTML(t, sel)) 300 | } 301 | }) 302 | }) 303 | } 304 | 305 | func TestHandler_Task__Running(t *testing.T) { 306 | for n, tk := range tasksRunning { 307 | t.Run(fmt.Sprintf("task_%d", n+1), func(t *testing.T) { 308 | path := fmt.Sprintf("/task/%s", tk.ID) 309 | _, doc := request(t, path) 310 | assertNav(t, doc, path) 311 | assertTask(t, doc, tk) 312 | }) 313 | } 314 | } 315 | 316 | func TestHandler_Task__Upcoming(t *testing.T) { 317 | for n, tk := range tasksUpcoming { 318 | t.Run(fmt.Sprintf("task_%d", n+1), func(t *testing.T) { 319 | path := fmt.Sprintf("/task/%s", tk.ID) 320 | _, doc := request(t, path) 321 | assertNav(t, doc, path) 322 | assertTask(t, doc, tk) 323 | }) 324 | } 325 | } 326 | 327 | func TestHandler_TaskCompleted__Succeeded(t *testing.T) { 328 | for n, tk := range tasksSucceeded { 329 | t.Run(fmt.Sprintf("task_%d", n+1), func(t *testing.T) { 330 | path := fmt.Sprintf("/completed/%s", tk.ID) 331 | _, doc := request(t, path) 332 | assertNav(t, doc, path) 333 | assertTaskCompleted(t, doc, tk) 334 | }) 335 | } 336 | } 337 | 338 | func TestHandler_TaskCompleted__Failed(t *testing.T) { 339 | for n, tk := range tasksFailed { 340 | t.Run(fmt.Sprintf("task_%d", n+1), func(t *testing.T) { 341 | path := fmt.Sprintf("/completed/%s", tk.ID) 342 | _, doc := request(t, path) 343 | assertNav(t, doc, path) 344 | assertTaskCompleted(t, doc, tk) 345 | }) 346 | } 347 | } 348 | 349 | func TestHandler_Task__IsCompleted(t *testing.T) { 350 | path := fmt.Sprintf("/task/%s", tasksSucceeded[0].ID) 351 | // A completed task can be passed in to the task route and it should redirect. 352 | _, doc := request(t, path) 353 | assertNav(t, doc, path) 354 | assertTaskCompleted(t, doc, tasksSucceeded[0]) 355 | } 356 | 357 | func TestHandler_Task__NotFound(t *testing.T) { 358 | path := fmt.Sprintf("/task/abcd") 359 | _, doc := request(t, path) 360 | assertNav(t, doc, path) 361 | text := doc.Find("div.page-body").Text() 362 | testutil.Equal(t, "body", "Task not found!", strings.TrimSpace(text)) 363 | } 364 | 365 | func TestHandler_TaskCompleted__NotFound(t *testing.T) { 366 | path := fmt.Sprintf("/completed/abcd") 367 | _, doc := request(t, path) 368 | assertNav(t, doc, path) 369 | text := doc.Find("div.page-body").Text() 370 | testutil.Equal(t, "body", "Task not found!", strings.TrimSpace(text)) 371 | } 372 | 373 | func assertNav(t *testing.T, doc *goquery.Document, path string) { 374 | brand := doc.Find(".navbar-brand") 375 | testutil.Equal(t, "brand", "Backlite", strings.TrimSpace(brand.Text())) 376 | 377 | links := doc.Find("header ul.navbar-nav li.nav-item") 378 | testutil.Equal(t, "nav", 4, links.Length()) 379 | links.Each(func(i int, sel *goquery.Selection) { 380 | link := sel.Find("a.nav-link") 381 | href, found := link.Attr("href") 382 | testutil.Equal(t, "href", true, found) 383 | 384 | switch i { 385 | case 0: 386 | testutil.Equal(t, "nav", "Running", strings.TrimSpace(link.Text())) 387 | testutil.Equal(t, "href", "/", href) 388 | testutil.Equal(t, "active", path == "/", sel.HasClass("active")) 389 | case 1: 390 | testutil.Equal(t, "nav", "Upcoming", strings.TrimSpace(link.Text())) 391 | testutil.Equal(t, "href", "/upcoming", href) 392 | testutil.Equal(t, "active", path == "/upcoming", sel.HasClass("active")) 393 | case 2: 394 | testutil.Equal(t, "nav", "Succeeded", strings.TrimSpace(link.Text())) 395 | testutil.Equal(t, "href", "/succeeded", href) 396 | testutil.Equal(t, "active", path == "/succeeded", sel.HasClass("active")) 397 | case 3: 398 | testutil.Equal(t, "nav", "Failed", strings.TrimSpace(link.Text())) 399 | testutil.Equal(t, "href", "/failed", href) 400 | testutil.Equal(t, "active", path == "/failed", sel.HasClass("active")) 401 | } 402 | }) 403 | } 404 | 405 | func assertPager(t *testing.T, doc *goquery.Document, path string) { 406 | pager := doc.Find("div.card-footer ul.pagination li.page-item") 407 | testutil.Equal(t, "pagination", 2, pager.Length()) 408 | u, err := url.Parse(path) 409 | if err != nil { 410 | t.Fatal("failed to parse path") 411 | } 412 | page := getPage(u) 413 | 414 | assertHref := func(sel *goquery.Selection, pageNum int) { 415 | href, exists := sel.Attr("href") 416 | testutil.Equal(t, "href", true, exists) 417 | testutil.Equal(t, "href", fmt.Sprintf("%s?page=%d", u.Path, pageNum), href) 418 | } 419 | 420 | pager.Each(func(i int, sel *goquery.Selection) { 421 | link := sel.Find("a.page-link") 422 | switch i { 423 | case 0: 424 | if page == 1 { 425 | testutil.Equal(t, "prev", true, sel.HasClass("disabled")) 426 | } 427 | testutil.Equal(t, "prev", "prev", strings.TrimSpace(link.Text())) 428 | assertHref(link, page-1) 429 | case 1: 430 | testutil.Equal(t, "next", "next", strings.TrimSpace(link.Text())) 431 | assertHref(link, page+1) 432 | } 433 | }) 434 | } 435 | 436 | func TestHandler_DBFailures(t *testing.T) { 437 | db, err := sql.Open("sqlite3", ":memory:") 438 | if err != nil { 439 | t.Fatal(err) 440 | } 441 | defer db.Close() 442 | h, err := NewHandler(Config{DB: db}) 443 | if err != nil { 444 | t.Fatal(err) 445 | } 446 | 447 | assert := func(t *testing.T, hf handleFunc, path string) { 448 | req := httptest.NewRequest(http.MethodGet, path, nil) 449 | rec := httptest.NewRecorder() 450 | handle(hf)(rec, req) 451 | 452 | testutil.Equal(t, "code", http.StatusInternalServerError, rec.Code) 453 | if !strings.Contains(rec.Body.String(), "no such table") { 454 | t.Error("error message not found") 455 | } 456 | } 457 | 458 | t.Run("running", func(t *testing.T) { 459 | assert(t, h.Running, "/") 460 | }) 461 | 462 | t.Run("upcoming", func(t *testing.T) { 463 | assert(t, h.Upcoming, "/upcoming") 464 | }) 465 | 466 | t.Run("succeeded", func(t *testing.T) { 467 | assert(t, h.Succeeded, "/succeeded") 468 | }) 469 | 470 | t.Run("failed", func(t *testing.T) { 471 | assert(t, h.Failed, "/failed") 472 | }) 473 | 474 | t.Run("task", func(t *testing.T) { 475 | assert(t, h.Task, "/task/1") 476 | }) 477 | 478 | t.Run("completed", func(t *testing.T) { 479 | assert(t, h.TaskCompleted, "/completed/1") 480 | }) 481 | } 482 | 483 | func TestHandler_NoTasks(t *testing.T) { 484 | db := testutil.NewDB(t) 485 | defer db.Close() 486 | h, err := NewHandler(Config{DB: db}) 487 | if err != nil { 488 | t.Fatal(err) 489 | } 490 | 491 | assert := func(t *testing.T, hf handleFunc, path string) { 492 | req := httptest.NewRequest(http.MethodGet, path, nil) 493 | rec := httptest.NewRecorder() 494 | handle(hf)(rec, req) 495 | 496 | doc, err := goquery.NewDocumentFromReader(rec.Body) 497 | if err != nil { 498 | t.Fatal(err) 499 | } 500 | 501 | assertNav(t, doc, path) 502 | assertPager(t, doc, path) 503 | body := doc.Find("div.table-responsive div.card-body") 504 | testutil.Equal(t, "message", "No tasks to display.", strings.TrimSpace(body.Text())) 505 | } 506 | 507 | t.Run("running", func(t *testing.T) { 508 | assert(t, h.Running, "/") 509 | }) 510 | 511 | t.Run("upcoming", func(t *testing.T) { 512 | assert(t, h.Upcoming, "/upcoming") 513 | }) 514 | 515 | t.Run("succeeded", func(t *testing.T) { 516 | assert(t, h.Succeeded, "/succeeded") 517 | }) 518 | 519 | t.Run("failed", func(t *testing.T) { 520 | assert(t, h.Failed, "/failed") 521 | }) 522 | } 523 | 524 | func TestHandler_Paging(t *testing.T) { 525 | db := testutil.NewDB(t) 526 | defer db.Close() 527 | h, err := NewHandler(Config{DB: db}) 528 | if err != nil { 529 | t.Fatal(err) 530 | } 531 | 532 | toClaim := make(task.Tasks, 0, 30) 533 | tx, err := db.Begin() 534 | if err != nil { 535 | t.Fatal(err) 536 | } 537 | 538 | for i := 0; i < 60; i++ { 539 | tk := &task.Task{ 540 | ID: fmt.Sprint(i), 541 | Queue: "a", 542 | Task: genTask(1), 543 | CreatedAt: time.Now(), 544 | } 545 | err := tk.InsertTx(context.Background(), tx) 546 | if err != nil { 547 | t.Fatal(err) 548 | } 549 | if i >= 30 { 550 | toClaim = append(toClaim, tk) 551 | } 552 | } 553 | 554 | if err := tx.Commit(); err != nil { 555 | t.Fatal(err) 556 | } 557 | 558 | err = toClaim.Claim(context.Background(), db) 559 | if err != nil { 560 | t.Fatal(err) 561 | } 562 | 563 | tx, err = db.Begin() 564 | if err != nil { 565 | t.Fatal(err) 566 | } 567 | 568 | for i := 0; i < 60; i++ { 569 | tk := &task.Completed{ 570 | ID: fmt.Sprint(i), 571 | Queue: "a", 572 | Attempts: 1, 573 | Succeeded: func() bool { 574 | if i%2 == 0 { 575 | return true 576 | } 577 | return false 578 | }(), 579 | CreatedAt: time.Now(), 580 | LastExecutedAt: time.Now(), 581 | } 582 | 583 | err := tk.InsertTx(context.Background(), tx) 584 | if err != nil { 585 | t.Fatal(err) 586 | } 587 | } 588 | 589 | if err := tx.Commit(); err != nil { 590 | t.Fatal(err) 591 | } 592 | 593 | assert := func(t *testing.T, hf handleFunc, path string) { 594 | var pagerPath string 595 | var expectedRows int 596 | 597 | for i := 1; i <= 2; i++ { 598 | switch i { 599 | case 1: 600 | expectedRows = h.cfg.ItemsPerPage 601 | pagerPath = path 602 | case 2: 603 | expectedRows = 5 604 | pagerPath = fmt.Sprintf("%s?page=%d", path, i) 605 | } 606 | 607 | req := httptest.NewRequest(http.MethodGet, pagerPath, nil) 608 | rec := httptest.NewRecorder() 609 | handle(hf)(rec, req) 610 | 611 | doc, err := goquery.NewDocumentFromReader(rec.Body) 612 | if err != nil { 613 | t.Fatal(err) 614 | } 615 | 616 | assertPager(t, doc, pagerPath) 617 | rows := doc.Find("tbody tr") 618 | testutil.Equal(t, "rows", expectedRows, rows.Length()) 619 | } 620 | } 621 | 622 | t.Run("running", func(t *testing.T) { 623 | assert(t, h.Running, "/") 624 | }) 625 | 626 | t.Run("upcoming", func(t *testing.T) { 627 | assert(t, h.Upcoming, "/upcoming") 628 | }) 629 | 630 | t.Run("succeeded", func(t *testing.T) { 631 | assert(t, h.Succeeded, "/succeeded") 632 | }) 633 | 634 | t.Run("failed", func(t *testing.T) { 635 | assert(t, h.Failed, "/failed") 636 | }) 637 | } 638 | 639 | func assertTask(t *testing.T, doc *goquery.Document, tk *task.Task) { 640 | expectedTitles := []string{ 641 | "Status", 642 | "Queue", 643 | "ID", 644 | "Created at", 645 | "Started", 646 | "Wait until", 647 | "Attempts", 648 | "Last executed at", 649 | "Data", 650 | } 651 | 652 | expectedContent := []string{ 653 | func() string { 654 | if tk.ClaimedAt == nil { 655 | return formatStatus("azure", "Upcoming", false) 656 | } 657 | return formatStatus("yellow", "Running", true) 658 | }(), 659 | tk.Queue, 660 | tk.ID, 661 | datetime(tk.CreatedAt), 662 | timeOrDash(tk.ClaimedAt), 663 | timeOrDash(tk.WaitUntil), 664 | fmt.Sprint(tk.Attempts), 665 | timeOrDash(tk.LastExecutedAt), 666 | formatJSON(tk.Task), 667 | } 668 | 669 | boxes := doc.Find("div.datagrid-item") 670 | testutil.Equal(t, "boxes", 9, boxes.Length()) 671 | boxes.Each(func(i int, sel *goquery.Selection) { 672 | title := sel.Find(".datagrid-title").Text() 673 | testutil.Equal(t, "title", expectedTitles[i], title) 674 | 675 | content := toHTML(t, sel.Find(".datagrid-content")) 676 | testutil.Equal(t, "content", expectedContent[i], content) 677 | }) 678 | } 679 | 680 | func assertTaskCompleted(t *testing.T, doc *goquery.Document, tk *task.Completed) { 681 | expectedTitles := []string{ 682 | "Status", 683 | "Queue", 684 | "ID", 685 | "Created at", 686 | "Last executed at", 687 | "Last duration", 688 | "Attempts", 689 | "Expires at", 690 | } 691 | 692 | expectedContent := []string{ 693 | func() string { 694 | if tk.Error != nil { 695 | return formatStatus("red", "Failed", false) 696 | } 697 | return formatStatus("green", "Succeeded", false) 698 | }(), 699 | tk.Queue, 700 | tk.ID, 701 | datetime(tk.CreatedAt), 702 | datetime(tk.LastExecutedAt), 703 | tk.LastDuration.String(), 704 | fmt.Sprint(tk.Attempts), 705 | func() string { 706 | if tk.ExpiresAt != nil { 707 | return datetime(*tk.ExpiresAt) 708 | } 709 | return "Never" 710 | }(), 711 | } 712 | 713 | if tk.Error != nil { 714 | expectedTitles = append(expectedTitles, "Error") 715 | errorTmpl := `` 716 | expectedContent = append(expectedContent, fmt.Sprintf(errorTmpl, *tk.Error)) 717 | } 718 | 719 | expectedTitles = append(expectedTitles, "Data") 720 | expectedContent = append(expectedContent, formatJSON(tk.Task)) 721 | 722 | boxes := doc.Find("div.datagrid-item") 723 | testutil.Equal(t, "boxes", len(expectedTitles), boxes.Length()) 724 | boxes.Each(func(i int, sel *goquery.Selection) { 725 | title := sel.Find(".datagrid-title").Text() 726 | testutil.Equal(t, "title", expectedTitles[i], title) 727 | 728 | content := toHTML(t, sel.Find(".datagrid-content")) 729 | testutil.Equal(t, "content", expectedContent[i], content) 730 | }) 731 | } 732 | 733 | func timeOrDash(t *time.Time) string { 734 | if t != nil { 735 | return datetime(*t) 736 | } 737 | return "-" 738 | } 739 | 740 | func formatStatus(color, label string, animated bool) string { 741 | var class string 742 | if animated { 743 | class = " status-dot-animated" 744 | } 745 | tag := `%s` 746 | return fmt.Sprintf(tag, color, class, label) 747 | } 748 | 749 | func formatJSON(b []byte) string { 750 | return "" + html.EscapeString(bytestring(b)) + "" 751 | } 752 | 753 | func toHTML(t *testing.T, sel *goquery.Selection) string { 754 | output, err := sel.Html() 755 | if err != nil { 756 | t.Fatal(err) 757 | } 758 | return strings.TrimSpace(output) 759 | } 760 | -------------------------------------------------------------------------------- /ui/query.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | const selectTask = ` 4 | SELECT 5 | id, 6 | queue, 7 | task, 8 | attempts, 9 | wait_until, 10 | created_at, 11 | last_executed_at, 12 | claimed_at 13 | FROM 14 | backlite_tasks 15 | WHERE 16 | id = ? 17 | ` 18 | 19 | const selectCompletedTask = ` 20 | SELECT 21 | id, 22 | created_at, 23 | queue text, 24 | last_executed_at, 25 | attempts, 26 | last_duration_micro, 27 | succeeded, 28 | task, 29 | expires_at, 30 | error 31 | FROM 32 | backlite_tasks_completed 33 | WHERE 34 | id = ? 35 | ` 36 | 37 | const selectRunningTasks = ` 38 | SELECT 39 | id, 40 | queue, 41 | null, 42 | attempts, 43 | wait_until, 44 | created_at, 45 | last_executed_at, 46 | claimed_at 47 | FROM 48 | backlite_tasks 49 | WHERE 50 | claimed_at IS NOT NULL 51 | LIMIT ? 52 | OFFSET ? 53 | ` 54 | const selectCompletedTasks = ` 55 | SELECT 56 | id, 57 | created_at, 58 | queue text, 59 | last_executed_at, 60 | attempts, 61 | last_duration_micro, 62 | succeeded, 63 | null, 64 | expires_at, 65 | error 66 | FROM 67 | backlite_tasks_completed 68 | WHERE 69 | succeeded = ? 70 | ORDER BY 71 | last_executed_at DESC 72 | LIMIT ? 73 | OFFSET ? 74 | ` 75 | -------------------------------------------------------------------------------- /ui/setup_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | "time" 11 | 12 | "github.com/PuerkitoBio/goquery" 13 | _ "github.com/mattn/go-sqlite3" 14 | "github.com/mikestefanello/backlite/internal/task" 15 | "github.com/mikestefanello/backlite/internal/testutil" 16 | ) 17 | 18 | var ( 19 | h *Handler 20 | mux *http.ServeMux 21 | now = time.Now() 22 | tasksRunning = task.Tasks{ 23 | { 24 | ID: "1", 25 | Queue: "testqueueR", 26 | Task: genTask(1), 27 | Attempts: 1, 28 | WaitUntil: nil, 29 | CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), 30 | LastExecutedAt: testutil.Pointer(time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)), 31 | }, 32 | { 33 | ID: "2", 34 | Queue: "testqueueR", 35 | Task: genTask(2), 36 | Attempts: 1, 37 | WaitUntil: nil, 38 | CreatedAt: time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), 39 | LastExecutedAt: nil, 40 | }, 41 | } 42 | tasksUpcoming = task.Tasks{ 43 | { 44 | ID: "3", 45 | Queue: "testqueueU", 46 | Task: genTask(3), 47 | Attempts: 2, 48 | WaitUntil: nil, 49 | CreatedAt: time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), 50 | LastExecutedAt: testutil.Pointer(time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)), 51 | }, 52 | { 53 | ID: "4", 54 | Queue: "testqueueUs", 55 | Task: genTask(4), 56 | Attempts: 0, 57 | WaitUntil: testutil.Pointer(time.Date(2025, 1, 8, 0, 0, 0, 0, time.UTC)), 58 | CreatedAt: time.Date(2025, 1, 7, 0, 0, 0, 0, time.UTC), 59 | LastExecutedAt: nil, 60 | }, 61 | } 62 | tasksSucceeded = []*task.Completed{ 63 | { 64 | ID: "5", 65 | Queue: "testqueueS", 66 | Task: genTask(5), 67 | Attempts: 4, 68 | Succeeded: true, 69 | LastDuration: 5 * time.Second, 70 | ExpiresAt: testutil.Pointer(time.Date(2025, 2, 8, 0, 0, 0, 0, time.UTC)), 71 | CreatedAt: time.Date(2025, 3, 9, 0, 0, 0, 0, time.UTC), 72 | LastExecutedAt: time.Date(2025, 3, 10, 0, 0, 0, 0, time.UTC), 73 | }, 74 | { 75 | ID: "6", 76 | Queue: "testqueueS", 77 | Task: genTask(6), 78 | Attempts: 3, 79 | Succeeded: true, 80 | LastDuration: 752 * time.Millisecond, 81 | ExpiresAt: nil, 82 | CreatedAt: time.Date(2025, 2, 11, 0, 0, 0, 0, time.UTC), 83 | LastExecutedAt: time.Date(2025, 2, 12, 0, 0, 0, 0, time.UTC), 84 | }, 85 | } 86 | tasksFailed = []*task.Completed{ 87 | { 88 | ID: "7", 89 | Queue: "testqueueF", 90 | Task: genTask(7), 91 | Attempts: 2, 92 | Succeeded: false, 93 | LastDuration: 5123 * time.Millisecond, 94 | ExpiresAt: testutil.Pointer(time.Date(2025, 5, 8, 0, 0, 0, 0, time.UTC)), 95 | CreatedAt: time.Date(2025, 5, 9, 0, 0, 0, 0, time.UTC), 96 | LastExecutedAt: time.Date(2025, 5, 10, 0, 0, 0, 0, time.UTC), 97 | Error: testutil.Pointer("bad thing happened"), 98 | }, 99 | { 100 | ID: "8", 101 | Queue: "testqueueF", 102 | Task: genTask(8), 103 | Attempts: 6, 104 | Succeeded: false, 105 | LastDuration: 72 * time.Second, 106 | ExpiresAt: nil, 107 | CreatedAt: time.Date(2025, 4, 11, 0, 0, 0, 0, time.UTC), 108 | LastExecutedAt: time.Date(2025, 4, 12, 0, 0, 0, 0, time.UTC), 109 | Error: testutil.Pointer("bad thing happened again!"), 110 | }, 111 | } 112 | ) 113 | 114 | func TestMain(m *testing.M) { 115 | var err error 116 | t := &testing.T{} 117 | db := testutil.NewDB(t) 118 | if t.Failed() { 119 | panic("failed to open database") 120 | } 121 | defer db.Close() 122 | 123 | h, err = NewHandler(Config{ 124 | DB: db, 125 | }) 126 | if err != nil { 127 | panic(err) 128 | } 129 | seedTestData() 130 | mux = http.NewServeMux() 131 | h.Register(mux) 132 | os.Exit(m.Run()) 133 | } 134 | 135 | func seedTestData() { 136 | tx, err := h.cfg.DB.Begin() 137 | if err != nil { 138 | panic(err) 139 | } 140 | 141 | for _, t := range tasksRunning { 142 | if err := t.InsertTx(context.Background(), tx); err != nil { 143 | panic(err) 144 | } 145 | } 146 | 147 | for _, t := range tasksUpcoming { 148 | if err := t.InsertTx(context.Background(), tx); err != nil { 149 | panic(err) 150 | } 151 | } 152 | 153 | for _, t := range tasksSucceeded { 154 | if err := t.InsertTx(context.Background(), tx); err != nil { 155 | panic(err) 156 | } 157 | } 158 | 159 | for _, t := range tasksFailed { 160 | if err := t.InsertTx(context.Background(), tx); err != nil { 161 | panic(err) 162 | } 163 | } 164 | 165 | if err := tx.Commit(); err != nil { 166 | panic(err) 167 | } 168 | 169 | if err := tasksRunning.Claim(context.Background(), h.cfg.DB); err != nil { 170 | panic(err) 171 | } 172 | 173 | for _, t := range tasksRunning { 174 | // Set a fixed time for when the running tasks were claimed so we can assert it in the UI. 175 | _, err := h.cfg.DB.Exec("UPDATE backlite_tasks SET claimed_at = ? WHERE id = ?", now.UnixMilli(), t.ID) 176 | if err != nil { 177 | panic(err) 178 | } 179 | t.ClaimedAt = testutil.Pointer(now) 180 | 181 | // Set the last executed time for tasks so we can assert it in the UI. 182 | if t.LastExecutedAt != nil { 183 | _, err := h.cfg.DB.Exec("UPDATE backlite_tasks SET last_executed_at = ? WHERE id = ?", t.LastExecutedAt.UnixMilli(), t.ID) 184 | if err != nil { 185 | panic(err) 186 | } 187 | } 188 | } 189 | 190 | for _, t := range tasksUpcoming { 191 | if t.LastExecutedAt != nil { 192 | _, err := h.cfg.DB.Exec( 193 | "UPDATE backlite_tasks SET attempts = ?, last_executed_at = ? WHERE id = ?", 194 | t.Attempts, 195 | t.LastExecutedAt.UnixMilli(), 196 | t.ID, 197 | ) 198 | 199 | if err != nil { 200 | panic(err) 201 | } 202 | } 203 | } 204 | } 205 | 206 | func genTask(num int) []byte { 207 | return []byte(fmt.Sprintf(`{"value": %d}`, num)) 208 | } 209 | 210 | func request(t *testing.T, url string) (*httptest.ResponseRecorder, *goquery.Document) { 211 | req := httptest.NewRequest(http.MethodGet, url, nil) 212 | rec := httptest.NewRecorder() 213 | mux.ServeHTTP(rec, req) 214 | 215 | doc, err := goquery.NewDocumentFromReader(rec.Body) 216 | if err != nil { 217 | t.Fatal(err) 218 | } 219 | return rec, doc 220 | } 221 | -------------------------------------------------------------------------------- /ui/templates.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "text/template" 7 | "time" 8 | ) 9 | 10 | //go:embed templates/*.gohtml 11 | var templates embed.FS 12 | 13 | var ( 14 | tmplTasksRunning = mustParse("running") 15 | tmplTasksUpcoming = mustParse("upcoming") 16 | tmplTasksCompleted = mustParse("completed_tasks") 17 | tmplTask = mustParse("task") 18 | tmplTaskCompleted = mustParse("completed_task") 19 | ) 20 | 21 | func mustParse(page string) *template.Template { 22 | t, err := template. 23 | New("layout.gohtml"). 24 | Funcs( 25 | template.FuncMap{ 26 | "bytestring": bytestring, 27 | "datetime": datetime, 28 | "add": add, 29 | }). 30 | ParseFS( 31 | templates, 32 | "templates/layout.gohtml", 33 | fmt.Sprintf("templates/%s.gohtml", page), 34 | ) 35 | 36 | if err != nil { 37 | panic(err) 38 | } 39 | return t 40 | } 41 | 42 | func bytestring(b []byte) string { 43 | return string(b) 44 | } 45 | 46 | func datetime(t time.Time) string { 47 | return t.Local().Format("02 Jan 2006 15:04:05 MST") 48 | } 49 | 50 | func add(a, b int) int { 51 | return a + b 52 | } 53 | -------------------------------------------------------------------------------- /ui/templates/completed_task.gohtml: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 |
5 | {{if .Content}} 6 | {{$task := .Content}} 7 |
8 |

Completed task

9 |
10 |
11 |
12 |
13 |
Status
14 |
15 | {{if $task.Succeeded}} 16 | Succeeded 17 | {{else}} 18 | Failed 19 | {{end}} 20 |
21 |
22 |
23 |
Queue
24 |
{{$task.Queue}}
25 |
26 |
27 |
ID
28 |
{{$task.ID}}
29 |
30 |
31 |
Created at
32 |
{{datetime $task.CreatedAt}}
33 |
34 |
35 |
Last executed at
36 |
{{datetime $task.LastExecutedAt}}
37 |
38 |
39 |
Last duration
40 |
{{$task.LastDuration}}
41 |
42 |
43 |
Attempts
44 |
{{$task.Attempts}}
45 |
46 |
47 |
Expires at
48 |
49 | {{if $task.ExpiresAt}} 50 | {{datetime $task.ExpiresAt}} 51 | {{else}} 52 | Never 53 | {{end}} 54 |
55 |
56 |
57 | {{if $task.Error}} 58 |
59 |
60 |
61 |
Error
62 |
63 | 64 |
65 |
66 |
67 | {{end}} 68 |
69 |
70 |
71 |
Data
72 |
73 | {{if $task.Task}} 74 | {{bytestring $task.Task}} 75 | {{else}} 76 | Not retained 77 | {{end}} 78 |
79 |
80 |
81 |
82 | {{else}} 83 | {{template "notfound" "Task not found!"}} 84 | {{end}} 85 |
86 |
87 |
88 | {{end}} -------------------------------------------------------------------------------- /ui/templates/completed_tasks.gohtml: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 |
5 |
6 | {{if .Content}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {{range .Content}} 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | {{end}} 33 | 34 |
QueueAttemptsCreated atLast executed atLast duration
{{.Queue}}{{.Attempts}}{{datetime .CreatedAt}}{{datetime .LastExecutedAt}}{{.LastDuration}} 29 | View 30 |
35 | {{else}} 36 | {{template "notfound" "No tasks to display."}} 37 | {{end}} 38 | {{template "pager" .}} 39 |
40 |
41 |
42 |
43 | {{end}} -------------------------------------------------------------------------------- /ui/templates/layout.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Backlite 5 | 6 | 7 | 8 |
9 | 57 |
58 |
59 |
60 | {{template "content" .}} 61 |
62 |
63 |
64 |
65 | 66 | 67 | 68 | {{define "pager"}} 69 | 90 | {{end}} 91 | 92 | {{define "notfound"}} 93 |
{{.}}
94 | {{end}} -------------------------------------------------------------------------------- /ui/templates/running.gohtml: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 |
5 |
6 | {{if .Content}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{range .Content}} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {{end}} 29 | 30 |
QueueAttemptCreated atStarted
{{.Queue}}{{.Attempts}}{{datetime .CreatedAt}}{{datetime .ClaimedAt}}View
31 | {{else}} 32 | {{template "notfound" "No tasks to display."}} 33 | {{end}} 34 | {{template "pager" .}} 35 |
36 |
37 |
38 |
39 | {{end}} -------------------------------------------------------------------------------- /ui/templates/task.gohtml: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 |
5 | {{if .Content}} 6 | {{$task := .Content}} 7 |
8 |

Task

9 |
10 |
11 |
12 |
13 |
Status
14 |
15 | {{if $task.ClaimedAt}} 16 | Running 17 | {{else}} 18 | Upcoming 19 | {{end}} 20 |
21 |
22 |
23 |
Queue
24 |
{{$task.Queue}}
25 |
26 |
27 |
ID
28 |
{{$task.ID}}
29 |
30 |
31 |
Created at
32 |
{{datetime $task.CreatedAt}}
33 |
34 |
35 |
Started
36 |
37 | {{if $task.ClaimedAt}} 38 | {{datetime $task.ClaimedAt}} 39 | {{else}} 40 | - 41 | {{end}} 42 |
43 |
44 |
45 |
Wait until
46 |
47 | {{if $task.WaitUntil}} 48 | {{datetime $task.WaitUntil}} 49 | {{else}} 50 | - 51 | {{end}} 52 |
53 |
54 |
55 |
Attempts
56 |
57 | {{$task.Attempts}} 58 |
59 |
60 |
61 |
Last executed at
62 |
63 | {{if $task.LastExecutedAt}} 64 | {{datetime $task.LastExecutedAt}} 65 | {{else}} 66 | - 67 | {{end}} 68 |
69 |
70 |
71 |
Data
72 |
73 | {{bytestring $task.Task}} 74 |
75 |
76 |
77 |
78 | {{else}} 79 | {{template "notfound" "Task not found!"}} 80 | {{end}} 81 |
82 |
83 |
84 | {{end}} -------------------------------------------------------------------------------- /ui/templates/upcoming.gohtml: -------------------------------------------------------------------------------- 1 | {{define "content"}} 2 |
3 |
4 |
5 |
6 | {{if .Content}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{range .Content}} 20 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 34 | {{end}} 35 | 36 |
QueueAttemptsCreated atLast executed at
{{.Queue}}{{.Attempts}}{{datetime .CreatedAt}} 26 | {{if .LastExecutedAt}} 27 | {{datetime .LastExecutedAt}} 28 | {{else}} 29 | Never 30 | {{end}} 31 | View
37 | {{else}} 38 | {{template "notfound" "No tasks to display."}} 39 | {{end}} 40 | {{template "pager" .}} 41 |
42 |
43 |
44 |
45 | {{end}} -------------------------------------------------------------------------------- /ui/templates_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMustParse_Panic(t *testing.T) { 8 | defer func() { 9 | if err := recover(); err == nil { 10 | t.Error("MustParse() did not panic") 11 | } 12 | }() 13 | mustParse("abc") 14 | } 15 | --------------------------------------------------------------------------------