SQLite
sqliteq is the SQLite adapter for varmq. It keeps jobs in SQLite database files, so jobs survive a restart of the process with no server to run. It provides persistent queues only: it has no distributed queue, so WithDistributedQueue does not take it. Read persistence and distribution first for what a persistent queue does.
Three steps set it up:
Add the two modules
sqliteq uses mattn/go-sqlite3, which is built with cgo, so building needs
CGO_ENABLED=1and a C compiler.terminalgo get github.com/goptics/varmq github.com/goptics/sqliteqOpen the store
sqliteq.Newtakes the path of a folder and returns aQueuesvalue and an error. The folder is created if it does not exist.main.gosdb, err := sqliteq.New("data") if err != nil { panic(err) } defer sdb.Close()Create a queue and bind it
Each queue gets its own database file in the folder, named after the queue in lower case: the queue
jobslives injobs.db. Both constructors take the name of the queue and return an error as well as the queue.main.gopq, err := sdb.NewQueue("jobs") if err != nil { panic(err) } defer pq.Close() queue := worker.WithPersistentQueue(pq)main.gopq, err := sdb.NewPriorityQueue("priority_jobs") if err != nil { panic(err) } defer pq.Close() queue := worker.WithPersistentPriorityQueue(pq) queue.Add("alert", 1) // a lower number runs first
A queue name may only hold letters, digits, ., _ and -. The files use write-ahead logging.
To keep every queue in one file instead, pass WithSingleFile(): the path is then the database file, with one table for each queue. That is also how to open a file written by an older sqliteq, which kept every queue in one file. New returns an error when the path does not fit the layout, such as a file path without WithSingleFile().
| Constructor | Order | Bind it with |
|---|---|---|
NewQueue |
first in, first out | WithPersistentQueue |
NewPriorityQueue |
lowest number first | WithPersistentPriorityQueue |
Both take the name of the queue and return an error as well as the queue. Within one process, asking for the same name again returns the same queue. WithRemoveOnComplete(false) is an option for either. It keeps an acknowledged job in the table, marked as completed, where the default deletes it.
A persistent queue
Section titled “A persistent queue”The program below binds a persistent queue to a worker with concurrency 1, adds ten jobs, and waits for them.
11 collapsed lines
package main
import ( "fmt" "time"
"github.com/goptics/sqliteq" "github.com/goptics/varmq")
func main() { sdb, err := sqliteq.New("data") if err != nil { panic(err) } defer sdb.Close()
pq, err := sdb.NewQueue("jobs") if err != nil { panic(err) } defer pq.Close()
worker := varmq.NewWorker( func(j varmq.Job[string]) { fmt.Println("processing", j.Data()) time.Sleep(time.Second) fmt.Println("processed", j.Data()) }, varmq.WithAutoRun(false), )
queue := worker.WithPersistentQueue(pq) fmt.Println("left in the file by an earlier run:", queue.Len())
// Start after the bind, so the worker looks at jobs already in the file. worker.Start()
for i := range 10 { queue.Add(fmt.Sprintf("task %d", i)) }
worker.WaitAndStop()}Three choices in it matter:
- The worker is created with
WithAutoRun(false)and started after the bind. Binding an sqliteq queue already makes the worker look for jobs a previous run left in the file, as the overview says. Starting it later only holds it back while the program counts them. queue.Len()is read beforeStart. It counts the jobs in the table that are waiting, not the ones a worker has taken.- Every job finishes before the deferred closes run.
WaitAndStoplets every job run, then stops the worker.
Run locally
Section titled “Run locally”-
Make a module, save the program as
main.goand add the dependencies:Terminal window mkdir sqlite-persistent && cd sqlite-persistentgo mod init sqlite-persistentgo get github.com/goptics/varmq github.com/goptics/sqliteq -
Run it with
go run .. It creates the folderdata, withjobs.dbin it. The first run printsleft in the file by an earlier run: 0, then aprocessingline and aprocessedline for each job, one second apart. Concurrency is 1, so they run in the order they were added. -
Run it again, and press Ctrl+C a few seconds in.
-
Run it a third time. It prints how many jobs are left, and the worker runs those first, before the ten new ones. The job that was running when you pressed Ctrl+C is one of them, which the next section explains.
Delete the data folder to start from an empty queue. SQLite also keeps jobs.db-wal and jobs.db-shm next to jobs.db while the file is open.
A priority queue
Section titled “A priority queue”NewPriorityQueue gives a queue that hands out the lowest number first. WithPersistentPriorityQueue binds it, and Add on the handle takes the priority after the data.
10 collapsed lines
package main
import ( "fmt"
"github.com/goptics/sqliteq" "github.com/goptics/varmq")
func main() { sdb, err := sqliteq.New("data") if err != nil { panic(err) } defer sdb.Close()
pq, err := sdb.NewPriorityQueue("priority_jobs") if err != nil { panic(err) } defer pq.Close()
worker := varmq.NewWorker( func(j varmq.Job[string]) { fmt.Println("processing", j.Data()) }, varmq.WithAutoRun(false), )
queue := worker.WithPersistentPriorityQueue(pq)
// A lower number runs first. queue.Add("report", 10) queue.Add("alert", 1) queue.Add("newsletter", 5)
worker.Start() worker.WaitAndStop()}Run locally
Section titled “Run locally”Make a module the way the persistent queue’s steps do, with this program as main.go and sqlite-priority as the module name, and run it. It creates data/priority_jobs.db and prints:
processing alertprocessing newsletterprocessing reportRun it again and it reopens the same file, adds three more jobs and prints the same three lines. Jobs with the same priority come out in the order they were added.
Jobs that were not acknowledged
Section titled “Jobs that were not acknowledged”The overview describes when varmq acknowledges a job. This is what sqliteq does with a job that was taken and not acknowledged, for example because the process died.
The scene below shows the table of a queue named jobs. Take a job, then kill the process and restart the program. Or open the file from a second process while the job runs.
A scene that shows the table of a sqliteq queue named jobs, one row per job, with a status of pending, processing or completed. Jobs 7, 8, 9 and 10 are pending. Take a job turns its row into processing in one statement. Acknowledge deletes the row, or marks it completed when you keep completed rows. Kill the process leaves the row processing. Restart the program opens the queue again, and sqliteq sets every processing row back to pending, so the job runs again. A second process that opens the same file does the same while the first process still runs the job, so the job can run twice, and the first process’s acknowledgement then fails.
data/jobs.dbtable jobs| id | status | data |
|---|
pq, err := sdb.NewQueue("jobs")- A job has a status in its row. A new row is
pending. When the worker takes the job, one statement turns the row intoprocessing.Acknowledgedeletes the row, or marks itcompletedwithWithRemoveOnComplete(false). It only matches a row that isprocessing. - Taken jobs return to
pendingwhen a queue is first opened. The first time a process opens a queue, withNewQueueorNewPriorityQueue, sqliteq sets everyprocessingrow that was not acknowledged back topending. It does this once per queue in each process, and at no other time. - So a job from a crashed run comes back on the next start. The job that was running when the process died is
processingin the file. The next program that creates the queue puts it back, and it runs again. There is no timeout. In the run above, that is the job you interrupted. - It also puts back jobs that are still running. sqliteq does not know which process took a row. Another process that opens a queue on the same file returns the first process’s running jobs to
pending, and they run twice. To avoid that, use one process for each database file. Lencountspendingrows only. A job that isprocessingis counted by the worker’sNumProcessing, not byLen.
Other details
Section titled “Other details”- A worker takes several jobs in one statement. sqliteq implements
IBatchAcknowledgeable, so a worker with only this queue claims as many jobs as it has free slots in one statement, and each one starts on its own goroutine at once. - Job IDs are checked. sqliteq implements
IDedupQueueandIDedupPriorityQueue. AnAddwithWithJobIdstores nothing while a row with that ID exists:pending,processing, orcompletedwithWithRemoveOnComplete(false). Once the job is acknowledged and its row deleted, the ID can be added again. Purgedeletes every row. That includes theprocessingrow of a job that is running. When the job finishes, itsAcknowledgefinds no row and returnsfalse, and the worker reportsErrAcknowledgeJobtoErrs()and the handler. The job’s function has run, so the error is about the bookkeeping.- There is no capacity. sqliteq does not implement
CapacitySetter, soWithQueueCapacitydoes not makeAddrefuse jobs. Closeonly stops the queue. It makes laterEnqueueandDequeuecalls on that queue fail, for every holder of it in the process. It does not close the database.Closeon theQueuesvalue does.
VarMQ is MIT licensed. Benchmark figures come from github.com/goptics/benchmarks.