Add queue tables

A table with `queue_table` set works as a job queue that many workers can
pull from at once. Claims use SELECT ... FOR UPDATE SKIP LOCKED over the
idx_queue_claim index order, so each job goes to one worker and only the
claimed rows are locked.

- Schema: adds the queue_* columns and the idx_queue_claim /
  idx_queue_lease indexes. Declaring these names yourself is an error.
- `queue_table` accepts `true` or { max_attempts, lease_ms, keep_completed }
- BunMariaDB.queue: push, claim, ack, fail, extend, cancel
- Statuses: pending, processing, done, failed, cancelled
- Leases use the database clock. Expired leases are reclaimed (or
  dead-lettered on the last attempt) and old lock tokens stop working.
- insert/update can't set queue state fields. update/delete skip claimed
  jobs.

Tests: allow the test container's self-signed certificate, and give the
queue test its own client so it doesn't reuse one closed by db.test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 15:43:22 +01:00
co-authored by Claude Opus 5.5
parent 688f4bcb58
commit 7c470e0225
16 changed files with 1091 additions and 4 deletions
+122
View File
@@ -29,6 +29,7 @@ A schema-driven MariaDB manager for [Bun](https://bun.sh), featuring automatic s
- [Update](#update)
- [Delete](#delete)
- [Raw SQL](#raw-sql)
- [Queue Tables](#queue-tables)
- [Query API Reference](#query-api-reference)
- [Vector Support](#vector-support)
- [HTML Sanitization](#html-sanitization)
@@ -243,6 +244,7 @@ interface BUN_MARIADB_TableSchemaType {
parentTableName?: string; // inherit / merge fields from another table
tableNameOld?: string; // rename: old name triggers ALTER TABLE RENAME
collation?: "utf8mb4_bin" | "utf8mb4_unicode_520_ci";
queue_table?: boolean | BUN_MARIADB_QueueTableOptions; // job queue, see Queue Tables
}
```
@@ -688,6 +690,126 @@ console.log(res.payload);
---
## Queue Tables
Set `queue_table: true` on a table to use it as a job queue. Any number of workers can pull from it at the same time: claims use `SELECT ... FOR UPDATE SKIP LOCKED` (MariaDB 10.6+), so no job is handed to two workers and workers don't wait on each other.
```ts
{
tableName: "email_jobs",
queue_table: true,
fields: [
{ fieldName: "to", dataType: "VARCHAR", dataLength: 255 },
{ fieldName: "subject", dataType: "VARCHAR", dataLength: 255 },
],
}
```
Pass an options object instead of `true` to set the queue's defaults. Arguments to the `queue` methods override them.
```ts
queue_table: {
max_attempts: 5, // attempts before a job is marked `failed` (default 5)
lease_ms: 60_000, // how long a claim lasts (default 30s)
keep_completed: true, // `ack` marks jobs `done` instead of deleting them (default false)
}
```
`bunx bun-mariadb schema` adds these columns, plus the `idx_queue_claim` and `idx_queue_lease` indexes. They are managed by the library: declaring a field or index with one of these names on a queue table is an error.
| Field | Type | Description |
| ------------------------ | --------------------------------------- | --------------------------------------------------- |
| `queue_status` | `ENUM(...)` | Job state, see below |
| `queue_available_at` | `BIGINT` (ms) | The job can't be claimed before this time |
| `queue_attempts` | `INT` | How many times the job has been claimed |
| `queue_lease_expires_at` | `BIGINT` (ms) | When the current claim expires |
| `queue_lock_token` | `VARCHAR(64)` | Token of the current claim |
| `queue_last_error` | `TEXT` | Error from the last failed attempt |
Statuses:
| Status | Meaning |
| ------------ | ----------------------------------------------------------------- |
| `pending` | Waiting to be claimed. The only status `claim` picks up |
| `processing` | Claimed by a worker |
| `done` | Acknowledged and kept (`keep_completed`). Otherwise jobs are deleted |
| `failed` | Used up its attempts (dead letter) |
| `cancelled` | Taken out of the queue with `queue.cancel` |
The statuses are fixed because the queue's SQL depends on them. For your own job outcomes (`sent`, `bounced`, ...), add a field of your own and set it before calling `ack`.
### Worker loop
```ts
await BunMariaDB.queue.push({
table: "email_jobs",
data: [{ to: "[email protected]", subject: "Hi" }],
delay_ms: 5_000, // optional
});
const { payload: jobs } = await BunMariaDB.queue.claim({
table: "email_jobs",
limit: 10,
lease_ms: 60_000,
});
for (const job of jobs || []) {
try {
await sendEmail(job);
await BunMariaDB.queue.ack({
table: "email_jobs",
id: job.id,
lock_token: job.queue_lock_token,
});
} catch (e: any) {
await BunMariaDB.queue.fail({
table: "email_jobs",
id: job.id,
lock_token: job.queue_lock_token,
error: e.message,
retry_delay_ms: 30_000,
});
}
}
```
| Method | Description |
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `queue.push({ table, data, delay_ms? })` | Insert jobs as `pending` |
| `queue.claim({ table, limit?, lease_ms?, max_attempts? })` | Claim up to `limit` (default 1) jobs, oldest first, for `lease_ms` (default 30s) |
| `queue.ack({ table, id, lock_token })` | Job done: deletes it, or marks it `done` with `keep_completed` |
| `queue.fail({ table, id, lock_token, error?, retry_delay_ms?, max_attempts? })` | Back to `pending` after `retry_delay_ms`, or `failed` once `max_attempts` (default 5) is reached |
| `queue.extend({ table, id, lock_token, lease_ms? })` | Extend the lease for a long-running job |
| `queue.cancel({ table, id })` | Take a `pending` job out of the queue. Claimed jobs can't be cancelled |
Delivery is **at least once**. If a worker crashes or runs past its lease, the next `claim` puts the job back in the queue (or marks it `failed` on its last attempt), and the next claim gets a new `queue_lock_token`. After that, `ack` / `fail` / `extend` with the old token return `success: false`. Make job handlers idempotent. Lease times use the database clock, so worker clock drift doesn't matter.
How regular CRUD treats queue tables:
- `insert` / `update` refuse to set `queue_status`, `queue_attempts`, `queue_lease_expires_at`, `queue_lock_token` or `queue_last_error`. Only the queue API changes those.
- `update` / `delete` skip jobs that are `processing`, so a worker's claimed job can't change or disappear while it runs.
- `select` is unchanged. Filter on `queue_status` to get the rows you want:
```ts
// Dead letters
await BunMariaDB.select({
table: "email_jobs",
query: { query: { queue_status: { value: "failed" } } },
});
// Everything still in the queue
await BunMariaDB.select({
table: "email_jobs",
count: true,
query: { query: { queue_status: { value: ["pending", "processing"], equality: "IN" } } },
});
```
- To retry a dead-lettered job, delete it and push it again, or reset it with raw `sql`.
- With `keep_completed`, `done` rows stay until you delete them, e.g. `delete` with a `queue_status` / `updated_at` filter.
---
## Query API Reference
`query` on `select` / `update` / `delete` accepts `ServerQueryParam<T>`: