# Que and the jobs that never wait on a row lock

> Que keeps background jobs inside the PostgreSQL database you already run and claims each one with an in-memory advisory lock. That design buys transactional safety and instant recovery from dead workers, at the cost of a job table that churns hard enough to need table maintenance.

**que-rb/que** — A Ruby job queue that uses PostgreSQL's advisory locks for speed and reliability.

- Repository: https://github.com/que-rb/que
- Stars: 2,325 · Forks: 196
- Language: Ruby
- License: MIT
- Published: 2026-10-14 · Updated: 2026-10-14 · Language: en
- Canonical page: https://hysenlabs.com/projects/que-rb-que

## Advisory locks free a dead worker's jobs immediately

Que is a job queue for Ruby and PostgreSQL that claims each job with a PostgreSQL advisory lock rather than a row lock. Advisory locks are held in memory, so taking one costs no disk write, and workers competing for the same job do not serialize behind each other in the way SELECT FOR UPDATE style locking forces them to. Under that arrangement the README puts the ceiling on throughput at CPU rather than at I/O, which is the opposite of the usual database-backed queue.

The crash case is the reason the lock type matters. Advisory locks disappear with the backend connection, so when a Ruby process dies mid-job the row is neither lost nor stuck in a locked or ambiguous state. Any other worker can pick it up straight away, with no operator clearing a stuck row and no reaper job in the picture.

The guarantee that survives is at-least-once execution, not exactly-once. Que marks the job destroyed for you after the run method returns, which leaves a gap: a process that dies after your side effects and before that automatic deletion will run the same job again. The project points readers at its own guidance on writing reliable jobs rather than pretending the gap does not exist.

## destroy has to sit inside your own transaction

Because jobs are rows in the same database, enqueueing happens inside a transaction you already opened. The example job class wraps its writes in User.transaction and calls destroy inside that same block, and the reasoning is mechanical rather than stylistic: if your changes and the job deletion share one commit, a crash cannot leave a job that ran without its record or a record deleted for work that never happened.

Leave the destroy call out and Que still cleans up after run returns, but the deletion then commits on its own, outside the transaction that carried your work. That is exactly the window in which a repeat becomes possible. The alternative is finish, which keeps the row for a queryable job history instead of deleting it, at the cost of a table that grows with every completed run.

Arguments do not cross the boundary as Ruby objects. Anything passed to enqueue is serialized to JSON and read back on the other side, which restricts you to strings, integers, floats, hashes and arrays. That is why the example passes card.id and a user_id: keyword rather than two ActiveRecord instances.

## Dead tuples slow the job table under constant writes

Que warns about its own table before it warns about anything else, and the warning is about bloat rather than correctness. A heavily written table accumulates dead tuples, and if Postgres cannot clean them up because a long-running transaction holds back the horizon, both the table and the queries against it slow down.

The advice it gives is to keep every transaction against the database that houses Que's job table as short as possible. That constraint belongs to your application code, not to Que's configuration. A report that streams thousands of rows, or a migration that wraps work in an open transaction, is enough to turn a healthy queue into a slow one while the workers keep running.

The other operational claim is that jobs and application data can be backed up and restored as one snapshot, so a recovery cannot leave queued work pointing at rows that no longer exist. That benefit is real for anyone who has restored a database from last night's dump and discovered a queue full of jobs referencing customers from two days earlier.

## A separate worker polls every five seconds by default

Nothing runs in the web process. Jobs are executed by a separate worker started outside your main server:

```bash
bundle exec que
```

The default poll interval is 5 seconds, adjustable through the `--poll-interval` runtime option, and `que -h` prints the option list. You can pass file paths to require so the worker can load your application; with Rails there is nothing to pass, because Que loads `config/environment.rb` when it exists. One process is multithreaded and runs many jobs at once.

The trade is operational coupling. Que does not need Redis or a broker, so there is no extra service to run and the PostgreSQL connection can be shared with ActiveRecord or Sequel. In exchange, job throughput is bounded by database capacity and job execution is bounded by the same machine that holds your data.

## Schema setup pins one migration version number

The queue schema is created from a migration, and Que requires you to name the target version rather than infer it:

```ruby
class CreateQueSchema < ActiveRecord::Migration[6.0]
  def up
    Que.migrate!(version: 7)
  end

  def down
    Que.migrate!(version: 0)
  end
end
```

Version 7 is what the current README example targets, and going back to version 0 removes Que entirely from the database. The instruction to always specify the version applies to every migration that touches Que, not just the first one, and the README directs readers to the changelog when they are unsure of the current number.

The docs on the master branch describe Que 2.x, with older major lines kept on separate branches. Compatibility is stated as MRI Ruby 2.7+, PostgreSQL 9.5+ and Rails 6.0+ as an optional layer, with Que 2 or newer required on Ruby 3. Rails users who dump their schema with ActiveRecord are told to set their schema_format to :sql rather than the default format, since Que's table is not a plain Ruby schema object.

## The compose file publishes 5697 and the app names no port

The repository ships its own development environment. `docker-compose.yml` defines three services: dev, which builds the Dockerfile's dev-environment target and runs as root inside the workspace, pg-dev, a psql client, and db, the database. Connections come from a single environment variable:

```yaml
DATABASE_URL: postgres://que:que@db/que-test
```

That string contains no port, so the app resolves db on the compose network and Postgres on its default 5432. The db service nevertheless publishes a host mapping:

```yaml
- 5697:5432
```

Read together, the two entries describe two different ways in, and the published port is never referenced by anything the app uses. A host-side psql pointed at 5697 and the DATABASE_URL your worker reads are reaching the same container by different routes, which is the kind of detail that costs an afternoon when a connection string is copied from the wrong place. The same file defaults the database image to a 13 release through `${POSTGRES_VERSION-13}`, well above the 9.5 floor the README lists as the compatibility minimum.

The Dockerfile pins the toolchain hard: a `ruby:3.2.2-slim-bookworm` base by digest, bundler installed at 2.4.22, `RUBYOPT=-W:deprecated` set globally, and libpq-dev installed in the shared base layer so every environment has the headers native gems need.

## Priority numbers run on the Linux scale

Que borrows the Linux convention where a lower number is more urgent, and a job with no explicit settings runs at priority 100. The example class sets both knobs as class-level defaults:

```ruby
self.run_at = proc { 1.minute.from_now }

self.priority = 10
```

The run_at value is a proc evaluated when the job is enqueued, so the delay is relative to the moment of the call rather than to the moment the class was loaded.

Per-call options override those class defaults without touching the class:

```ruby
ChargeCreditCard.enqueue(card.id, user_id: current_user.id, job_options: { run_at: 1.day.from_now, priority: 5 })
```

Priority 5 is more urgent than the class-level 10, so a single enqueue can outrank its own job type. Queueing calls made inside a transaction block, as the example does, get the job row created in the same commit. The README does not document automatic retry with backoff on failure, and it does not document any mechanism for replaying a dead-lettered job, so failures are handled by whatever you write inside run.

## Conclusion

Que fits services that already run PostgreSQL and cannot afford a job surviving a deploy without its data, and it fits teams willing to own job table maintenance. It does not fit anyone who needs scheduled retries with backoff, priority routing across many queues, or a separate queue service that scales independently of the database. Check first that your longest transactions stay short, then verify on a copy of production data how fast the job table grows before you move a busy application onto it.

## FAQ

### What does Que need to run?

Que states compatibility as MRI Ruby 2.7+, PostgreSQL 9.5+ and Rails 6.0+ as an optional layer, and Que 2 or newer is required for Ruby 3. It installs as a gem and creates its schema from a migration.

### Does Que lose jobs when a worker process crashes?

No. Advisory locks live in memory and disappear with the process, so the job becomes available for another worker immediately rather than being left locked. A crash can still cause a repeat, which is why the example job calls destroy inside its own transaction.

### Can I pass ActiveRecord objects to a Que job?

Not directly. Arguments are serialized to JSON and back, so the project limits you to simple types such as strings, integers, floats, hashes and arrays. The example passes card.id and a user_id: keyword instead of model instances.

### How do I run Que jobs in the repository's Docker setup?

The compose file builds a dev environment from the Dockerfile's dev-environment target and starts a db service with credentials que and que, reachable through DATABASE_URL as postgres://que:que@db/que-test. The db service also publishes host port 5697 against container port 5432.

### How do I remove Que from an existing database?

Migrate the schema back to version 0 in the down method of your migration, using Que.migrate!(version: 0). Any migration that touches Que should name its target version explicitly.

## Sources

- [Issues](https://github.com/que-rb/que/issues)
- [License: MIT](https://github.com/que-rb/que/blob/master/LICENSE)
- [que-rb/que on GitHub](https://github.com/que-rb/que)
- [README](https://github.com/que-rb/que/blob/master/README.md)
- [Releases](https://github.com/que-rb/que/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/que-rb-que
