DEV Community

Cover image for Goravel queues and jobs: retries and failed jobs
Krishan
Krishan

Posted on Edited on Originally published at devchalk.com

Goravel queues and jobs: retries and failed jobs

Part 16 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-16.

This part adds a CSV export of all tasks. A big export takes a while, so the request doesn't build it. It only queues a job and returns. The job builds the file in the background, and the client downloads it when it's done.

Start from the Events & Listeners code:

git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-15
cp .env.example .env
go run . artisan key:generate
go run . artisan jwt:secret
docker compose up -d
go run . artisan migrate:fresh --seed
docker compose exec postgres psql -U goravel -d taskflow -c "CREATE DATABASE taskflow_test"
go install github.com/air-verse/air@latest
air
Enter fullscreen mode Exit fullscreen mode

A job, not an event

An event announces what happened, and whoever listens decides what to do about it. That fit Events & Listeners, where the notification was one reaction to assigning a task, and more could be added later without touching the handler. For an export, the handler already knows the one thing that has to happen, and the export isn't done until the file is built. So it names that work directly by sending a job. The job runs on the same queue and worker as the queued listener.

The exports table

Each export is a row with its owner, its status and the file's path. The path stays out of the JSON:

app/enums/export_status.go

package enums

type ExportStatus string

const (
    ExportPending ExportStatus = "pending"
    ExportDone    ExportStatus = "done"
)
Enter fullscreen mode Exit fullscreen mode

app/models/export.go

package models

import (
    "github.com/goravel/framework/database/orm"

    "taskflow/app/enums"
)

type Export struct {
    orm.Model
    UserID uint               `gorm:"not null" json:"user_id"`
    Status enums.ExportStatus `gorm:"type:enum('pending','done');default:'pending';not null" json:"status"`
    Path   string             `gorm:"size:255" json:"-"`
}
Enter fullscreen mode Exit fullscreen mode

make:migration builds the migration from a model, but it only finds models listed in Models in bootstrap/app.go, so Export goes there first:

bootstrap/app.go

 // ... 42 lines not shown
        WithCallback(func() {
            facades.Schema().Extend(schema.Extension{
-               Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}},
+               Models: []any{models.User{}, models.Task{}, models.Attachment{}, models.Notification{}, models.Export{}},
            })

            // ... 34 lines not shown
Enter fullscreen mode Exit fullscreen mode
$ go run . artisan make:migration create_exports_table -m Export
Enter fullscreen mode Exit fullscreen mode

make:migration builds the columns from the model, and one more line adds the foreign key to users:

database/migrations/20261026231209_create_exports_table.go

// ... 16 lines not shown
func (r *M20261026231209CreateExportsTable) Up() error {
    if !facades.Schema().HasTable("exports") {
        return facades.Schema().Create("exports", func(table schema.Blueprint) {
            table.TimestampTz("created_at").Nullable()
            table.TimestampTz("updated_at").Nullable()
            table.BigIncrements("id")
            table.UnsignedBigInteger("user_id")
            table.Enum("status", []any{"pending", "done"}).Default("pending")
            table.String("path", 255)

            table.Foreign("user_id").References("id").On("users")
        })
    }
// ... 8 lines not shown
Enter fullscreen mode Exit fullscreen mode
$ go run . artisan migrate
Enter fullscreen mode Exit fullscreen mode

The job

make:job creates the job and registers it:

$ go run . artisan make:job ExportTasks
 SUCCESS  Job created successfully
 SUCCESS  Job registered successfully
Enter fullscreen mode Exit fullscreen mode

WithEvents registers each listener with its event. A job has no event, so make:job registers it in a new bootstrap/jobs.go:

bootstrap/jobs.go

package bootstrap

import (
    "github.com/goravel/framework/contracts/queue"

    "taskflow/app/jobs"
)

func Jobs() []queue.Job {
    return []queue.Job{
        &jobs.ExportTasks{},
    }
}
Enter fullscreen mode Exit fullscreen mode

It also adds WithJobs to bootstrap/app.go. Without it, the worker can't find the job:

bootstrap/app.go

 // ... 26 lines not shown
 func Boot() contractsfoundation.Application {
    return foundation.Setup().
+       WithJobs(Jobs).
        WithRules(Rules).
        WithSeeders(Seeders).
        WithMigrations(Migrations).
        // ... 50 lines not shown
Enter fullscreen mode Exit fullscreen mode

The job takes the export's id, writes the CSV to the disk from File Storage and marks the export done:

app/jobs/export_tasks.go

// ... 16 lines not shown

type ExportTasks struct {
}

func NewExportTasks(exportID uint) (*ExportTasks, []queue.Arg) {
    return &ExportTasks{}, []queue.Arg{
        {Type: "uint", Value: exportID},
    }
}

// Signature The name and signature of the job.
func (receiver *ExportTasks) Signature() string {
    return "export_tasks"
}

// Handle Execute the job.
func (receiver *ExportTasks) Handle(args ...any) error {
    exportID, ok := args[0].(uint)
    if !ok {
        return errors.New("export tasks: the argument must be the export id")
    }

    var export models.Export
    err := facades.Orm().Query().FindOrFail(&export, exportID)
    if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
        return nil
    }
    if err != nil {
        return err
    }

    var tasks []models.Task
    if err := facades.Orm().Query().With("Assignee").OrderBy("id").Find(&tasks); err != nil {
        return err
    }

    rows := [][]string{{"id", "title", "status", "assignee", "due_at"}}
    for _, task := range tasks {
        assignee, dueAt := "", ""
        if task.Assignee != nil {
            assignee = task.Assignee.Name
        }
        if task.DueAt != nil {
            dueAt = task.DueAt.ToDateTimeString()
        }
        rows = append(rows, []string{strconv.Itoa(int(task.ID)), task.Title, string(task.Status), assignee, dueAt})
    }

    var file bytes.Buffer
    if err := csv.NewWriter(&file).WriteAll(rows); err != nil {
        return err
    }

    path := fmt.Sprintf("exports/%d.csv", export.ID)
    if err := facades.Storage().Put(path, file.String()); err != nil {
        return err
    }

    export.Status = enums.ExportDone
    export.Path = path

    return facades.Orm().Query().Save(&export)
}
Enter fullscreen mode Exit fullscreen mode
  • NewExportTasks builds the job and its args together, as NewTaskAssigned does for the event in Events & Listeners. Every call then passes the export's id, and always as a uint.
  • Signature is the job's name, export_tasks. The worker finds the job by it and prints it.
  • Handle reads the export's id from its args and checks its type. Args have to be plain values, as with the queued listener, so the job gets the id and loads the export itself. If the export is gone, an error would only put a job that can never succeed in failed_jobs, so it returns nil.
  • It writes one CSV row per task to exports/<id>.csv and marks the export done.
  • Returning an error tells the worker the job failed.

Starting an export

POST /exports saves a pending export and sends the job. It then answers 202 Accepted, which means the request was accepted but the work isn't finished. The client reads the status from GET /exports/{id}, and once it's done, GET /exports/{id}/download sends the file:

app/http/controllers/export_controller.go

// ... 28 lines not shown
    export := models.Export{
        UserID: authuser.From(ctx).ID,
        Status: enums.ExportPending,
    }
    if err := facades.Orm().WithContext(ctx).Query().Create(&export); err != nil {
        facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not start the export.",
        })
    }

    err := facades.Queue().Job(jobs.NewExportTasks(export.ID)).Dispatch()
    if err != nil {
        facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not start the export.",
        })
    }

    return ctx.Response().Json(202, export)
}

// ... 20 lines not shown
func (r *ExportController) Download(ctx http.Context) http.Response {
    var export models.Export
    // ... 14 lines not shown
    if export.Status != enums.ExportDone {
        return ctx.Response().Json(409, map[string]any{
            "error": "The export is not ready yet.",
        })
    }

    content, err := facades.Storage().GetBytes(export.Path)
    if err != nil {
        facades.Log().Error(err)
        return ctx.Response().Json(500, map[string]any{
            "error": "Could not load the export.",
        })
    }

    disposition := mime.FormatMediaType("attachment", map[string]string{"filename": fmt.Sprintf("tasks-%d.csv", export.ID)})

    return ctx.Response().
        Header("Content-Disposition", disposition).
        Data(200, "text/csv", content)
}
Enter fullscreen mode Exit fullscreen mode
  • NewExportTasks gives Job the job and its one arg, the export's id, and Dispatch puts the job on the queue.
  • Show and Download only look among the user's own exports, so an export id that belongs to someone else gives 404.
  • Download answers 409 Conflict until the export is done.

routes/api.go

 // ... 15 lines not shown
    attachmentController := controllers.NewAttachmentController()
    notificationController := controllers.NewNotificationController()
+   exportController := controllers.NewExportController()

    facades.Route().Prefix("api/v1").Group(func(router route.Router) {
        router.Post("register", authController.Register)
        // ... 18 lines not shown
            router.Post("notifications/{id}/read", notificationController.Read)

+           router.Post("exports", exportController.Store)
+           router.Get("exports/{id}", exportController.Show)
+           router.Get("exports/{id}/download", exportController.Download)
+
            router.Get("users", userController.Index)
            router.Get("users/{id}", userController.Show)
        })
    })
 }
Enter fullscreen mode Exit fullscreen mode

See it work

The run starts from the same setup as Gates & Policies:

  1. Reset the database with migrate:fresh --seed, which adds Ana as the manager, plus three members and seven tasks.
  2. Delete storage/app/exports, so files from an earlier run can't stop the failure later on.
  3. Register Ben. He gets id 5.
  4. Log in as Ana and as Ben, and save each token in a variable, ANA and BEN.

The export is pending when the request returns, and done a moment later:

$ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":1,"user_id":1,"status":"pending"}

$ sleep 2
$ curl http://localhost:3000/api/v1/exports/1 -H "Authorization: Bearer $ANA"
{…,"id":1,"user_id":1,"status":"done"}

$ curl -D - http://localhost:3000/api/v1/exports/1/download -H "Authorization: Bearer $ANA"
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=tasks-1.csv
Content-Length: 466
Content-Type: text/csv
Date: …

id,title,status,assignee,due_at
1,Review the pull request,done,Francesco Bradley,2026-11-04 09:00:00
2,Review the pull request,pending,Francesco Bradley,2026-11-25 09:00:00
…

$ curl http://localhost:3000/api/v1/exports/1 -H "Authorization: Bearer $BEN"
{"error":"The export does not exist."}
Enter fullscreen mode Exit fullscreen mode

The seeder makes up names and dates, so the CSV rows differ after each reset.

The worker runs inside the app, so its lines show up next to the server's own output. It prints one line when a job starts and one when it ends, each with the job's signature:

  2026-10-27 00:09:53.215 export_tasks ............................... RUNNING
  2026-10-27 00:09:53.222 export_tasks .......................... 6.121ms DONE
Enter fullscreen mode Exit fullscreen mode

When the job fails

The job can't create a file in a read-only folder, so chmod 555 on storage/app/exports makes it fail. POST /exports still answers 202, because it only queues the job, but the export stays pending and Download refuses the file:

$ chmod 555 storage/app/exports

$ curl -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"pending"}

$ curl http://localhost:3000/api/v1/exports/2 -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"pending"}

$ curl http://localhost:3000/api/v1/exports/2/download -H "Authorization: Bearer $ANA"
{"error":"The export is not ready yet."}
Enter fullscreen mode Exit fullscreen mode

The worker tries the job once and gives up:

  2026-10-27 00:09:55.231 export_tasks ............................... RUNNING
  2026-10-27 00:09:55.235 export_tasks .......................... 3.839ms FAIL
Enter fullscreen mode Exit fullscreen mode

The worker saves a failed job in the failed_jobs table, with its args and the error. queue:failed lists those jobs but not the error, so psql reads it from the table:

$ go run . artisan queue:failed
  2026-10-27 00:09:57 00950ba2-2c5b-43dd-9cd8-d757ce5d198b ..... redis@default

$ docker compose exec postgres psql -U goravel taskflow -At -c "select exception from failed_jobs"
open …/storage/app/exports/2.csv: permission denied
Enter fullscreen mode Exit fullscreen mode

Nothing marks the export as failed, so it stays pending. Once the folder is writable again, queue:retry takes the job's uuid, the one queue:failed listed, and puts that job back on the queue. The worker then runs it and finishes the export:

$ chmod 755 storage/app/exports

$ go run . artisan queue:retry 00950ba2-2c5b-43dd-9cd8-d757ce5d198b
  INFO    Pushing failed queue jobs back onto the queue

  00950ba2-2c5b-43dd-9cd8-d757ce5d198b ........................... 2.91ms DONE

$ curl http://localhost:3000/api/v1/exports/2 -H "Authorization: Bearer $ANA"
{…,"id":2,"user_id":1,"status":"done"}
Enter fullscreen mode Exit fullscreen mode

queue:retry also takes several uuids at once, and with none it retries every failed job.

Trying again by itself

Some failures go away after a few seconds, like a database that's restarting, so ShouldRetry lets the job try again:

app/jobs/export_tasks.go

 package jobs

 import (
    "bytes"
    "encoding/csv"
    "errors"
    "fmt"
    "strconv"
+   "time"

    "github.com/goravel/framework/contracts/queue"
    // ... 67 lines not shown
    return facades.Orm().Query().Save(&export)
 }
+
+// ShouldRetry gives a failed export two more tries, five seconds apart.
+func (receiver *ExportTasks) ShouldRetry(err error, attempt int) (bool, time.Duration) {
+   return attempt < 3, 5 * time.Second
+}
Enter fullscreen mode Exit fullscreen mode

After each failed try, the worker calls ShouldRetry with the error and the try's number, starting at 1. ShouldRetry returns whether to try again and how long to wait. Here attempt < 3, 5 * time.Second means another try after tries 1 and 2, each five seconds later. After the third failed try, the worker saves the job in failed_jobs.

The worker runs one job at a time, and it sleeps through each five-second wait itself. So a job queued behind a failing export has to wait for all three tries. The block below makes the folder read-only again, starts an export, then assigns a task, which queues a notification:

$ chmod 555 storage/app/exports

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/v1/exports -H "Authorization: Bearer $ANA"
202

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/v1/tasks \
  -H "Authorization: Bearer $ANA" \
  -H "Content-Type: application/json" \
  -d '{"title":"Fix the login page","description":"The error message overlaps the button.","assigned_to":5}'
201

$ sleep 2
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[],"meta":{"page":1,"per_page":15,"total":0}}

$ sleep 10
$ curl http://localhost:3000/api/v1/notifications -H "Authorization: Bearer $BEN"
{"data":[{…,"id":1,"user_id":5,"task_id":8,"message":"Ana assigned you \"Fix the login page\"","read_at":null}],"meta":{"page":1,"per_page":15,"total":1}}
Enter fullscreen mode Exit fullscreen mode

The notification only ran after the export gave up, as the worker's log shows:

  2026-10-27 00:10:06.816 export_tasks ............................... RUNNING
  2026-10-27 00:10:16.839 export_tasks .......................... 6.905ms FAIL
  2026-10-27 00:10:16.842 notify_assignee ............................ RUNNING
  2026-10-27 00:10:16.847 notify_assignee ........................ 4.77ms DONE
Enter fullscreen mode Exit fullscreen mode

The figure steps through both cases, an export that works and this one:

Interactive figure on DevChalk: An export that works, then one that fails three times while a notification waits behind it.

An export that works: the client sends POST /exports, the API saves a pending export in Postgres, puts an export_tasks job on Redis and answers 202. The worker takes the job, loads the tasks, writes the CSV to the disk and marks the export done. An export that fails: the API answers 202 as before, but the worker can't write the file, waits five seconds and tries again, three times, then saves the job in failed_jobs. The notification job waits on Redis the whole time and runs only after the export gives up.

Two jobs at once

concurrent is a key on a connection in config/queue.go. It sets how many jobs the worker runs at the same time, and it defaults to 1. The redis connection doesn't set it, so it runs one job at a time until it's set to 2:

config/queue.go

 // ... 21 lines not shown
            "database": map[string]any{
                "driver":     "database",
                "connection": config.Env("DB_CONNECTION"),
                "queue":      "default",
                "concurrent": 1,
            },
            "redis": map[string]any{
                "driver":     "custom",
                "connection": "default",
                "queue":      "default",
+               "concurrent": 2,
                "via": func() (queue.Driver, error) {
                    return redisfacades.Queue("redis") // The `redis` value is the key of `connections`
                },
            // ... 13 lines not shown
Enter fullscreen mode Exit fullscreen mode

With the same two requests, the notification is there after 2 seconds. In the log, it starts at once, while the export is still retrying:

  2026-10-27 00:10:25.057 export_tasks ............................... RUNNING
  2026-10-27 00:10:25.057 notify_assignee ............................ RUNNING
  2026-10-27 00:10:25.074 notify_assignee ...................... 17.074ms DONE
  2026-10-27 00:10:35.08 export_tasks ........................... 6.471ms FAIL
Enter fullscreen mode Exit fullscreen mode

Make the folder writable again with chmod 755 storage/app/exports.

A queue of its own for exports:

Exports could also go on a queue of their own with OnQueue:

facades.Queue().Job(jobs.NewExportTasks(export.ID)).OnQueue("exports").Dispatch()
Enter fullscreen mode Exit fullscreen mode

The worker that app.Start() runs reads one queue, the one named by the default connection's queue key. That's default in TaskFlow, as the start-up line says: Processing jobs from [redis] connection and [default] queue. A job on exports needs a second worker for that queue, which a runner added in WithRunners can start. With its own worker, a slow export never holds up the jobs on default.

Sending a job later with Delay:

Delay sends a job later:

facades.Queue().Job(jobs.NewExportTasks(export.ID)).Delay(time.Now().Add(time.Hour)).Dispatch()
Enter fullscreen mode Exit fullscreen mode

On redis, the job waits until then. On sync, which the tests use, Dispatch itself sleeps until then, so a test with that delay would take an hour. An export should start as soon as it's asked for, so TaskFlow doesn't use Delay.

Tests

The tests are in tests/feature/export_test.go. They use the sync connection from Events & Listeners, so the job runs inside POST /exports. They check that:

  • the export is done
  • the download is the CSV
  • other users get 404
  • a pending export can't be downloaded
$ go test ./tests/... --env=.env.testing
?       taskflow/tests  [no test files]
ok      taskflow/tests/feature  1.542s
ok      taskflow/tests/unit 0.398s
Enter fullscreen mode Exit fullscreen mode

Exports now run in the background and retry when they fail. An export that fails every try still shows as pending.


Previous: 15. Events & Listeners
Next: 17. Mail & Templates
Series: Goravel

Top comments (0)