├── .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 | [](https://goreportcard.com/report/github.com/mikestefanello/backlite)
4 | [](https://github.com/mikestefanello/backlite/actions/workflows/test.yml)
5 | [](https://opensource.org/licenses/MIT)
6 | [](https://pkg.go.dev/github.com/mikestefanello/backlite)
7 | [](https://go.dev)
8 | [](https://raw.githack.com/wiki/mikestefanello/backlite/coverage.html)
9 |
10 |

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 |
63 |
64 |
65 |
66 |
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 := `%s
`
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 |
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 |
{{$task.Error}}
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 | Queue |
12 | Attempts |
13 | Created at |
14 | Last executed at |
15 | Last duration |
16 | |
17 |
18 |
19 |
20 | {{range .Content}}
21 |
22 | |
23 | {{.Queue}} |
24 | {{.Attempts}} |
25 | {{datetime .CreatedAt}} |
26 | {{datetime .LastExecutedAt}} |
27 | {{.LastDuration}} |
28 |
29 | View
30 | |
31 |
32 | {{end}}
33 |
34 |
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 |
10 |
11 |
14 |
15 | Backlite
16 |
17 |
55 |
56 |
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 | Queue |
12 | Attempt |
13 | Created at |
14 | Started |
15 | |
16 |
17 |
18 |
19 | {{range .Content}}
20 |
21 | |
22 | {{.Queue}} |
23 | {{.Attempts}} |
24 | {{datetime .CreatedAt}} |
25 | {{datetime .ClaimedAt}} |
26 | View |
27 |
28 | {{end}}
29 |
30 |
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 |
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 | Queue |
12 | Attempts |
13 | Created at |
14 | Last executed at |
15 | |
16 |
17 |
18 |
19 | {{range .Content}}
20 |
21 | |
22 | {{.Queue}} |
23 | {{.Attempts}} |
24 | {{datetime .CreatedAt}} |
25 |
26 | {{if .LastExecutedAt}}
27 | {{datetime .LastExecutedAt}}
28 | {{else}}
29 | Never
30 | {{end}}
31 | |
32 | View |
33 |
34 | {{end}}
35 |
36 |
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 |
--------------------------------------------------------------------------------