Enfileirar é uma transação, duas tabelas
O curso de nível Júnior "Solid Queue: jobs em background" listou as onze tabelas solid_queue_* desta aplicação e leu o config/queue.yml linha por linha. Parou nos nomes. Este curso lê o código que move linhas entre essas tabelas, usando a solid_queue 1.3.2. Ele captura o SQL que cada movimento produz no banco de filas de desenvolvimento desta aplicação, e roda um worker de verdade contra ele.
Comece por um retrato de quando esta aula foi escrita. O banco de filas de desenvolvimento guardava 45 jobs, 26 ActiveStorage::AnalyzeJob e 19 ActiveStorage::PurgeJob. Todos foram enfileirados em 4 de março de 2026, todos continuavam em solid_queue_ready_executions, e nenhum tinha terminado. SolidQueue::Queue.find_by_name("default").human_latency reportava "5 months, 4 weeks, 1 day".
Não havia nada de errado com eles. Em 30 de agosto, o commit 5cfccfa trocou no config/environments/development.rb o queue_adapter = :solid_queue por :async. Nenhum processo consultou este banco depois disso, porque o bin/dev não sobe processo de jobs e o SOLID_QUEUE_IN_PUMA não está definido em desenvolvimento. Eles eram um espécime ideal: enfileiramentos congelados onde a transação de enqueue os deixou. Esse banco já foi limpo, mas as linhas abaixo são daquele retrato.
O que o perform_later grava
ActiveJob::QueueAdapters::SolidQueueAdapter#enqueue é uma linha, SolidQueue::Job.enqueue(active_job), e o Job.enqueue é um create! com estes atributos:
def attributes_from_active_job(active_job)
{
queue_name: active_job.queue_name || DEFAULT_QUEUE_NAME,
active_job_id: active_job.job_id,
priority: active_job.priority || DEFAULT_PRIORITY,
scheduled_at: active_job.scheduled_at,
class_name: active_job.class.name,
arguments: active_job.serialize,
concurrency_key: active_job.concurrency_key
}
end
arguments não são os seus argumentos. É o active_job.serialize, o envelope inteiro do Active Job em JSON: job_class, job_id, queue_name, arguments, executions, exception_executions, locale, timezone, enqueued_at e scheduled_at. A linha mais antiga carregava "arguments" => [{"_aj_globalid" => "gid://newbie-on-rails/ActiveStorage::Blob/30"}] dentro desse envelope, mais "executions" => 0 e "locale" => "en". A aula 4 mostra por que importa, para as novas tentativas, que executions more dentro do JSON.
Um callback grava a segunda tabela. SolidQueue::Job::Executable declara after_create :prepare_for_execution:
def prepare_for_execution
if due? then dispatch
else
schedule
end
end
def dispatch
if acquire_concurrency_lock then ready
else
handle_concurrency_conflict
end
end
private
def ready
ReadyExecution.create_or_find_by!(job_id: id)
end
due? é scheduled_at.nil? || scheduled_at <= Time.current. Um perform_later simples define scheduled_at como agora, então o job está vencido. Sem chave de concorrência, acquire_concurrency_lock devolve true sem tocar no banco. Aqui está o resultado, capturado do log de queries desta aplicação para um job numa fila descartável probe, sem as linhas de concorrência que pertencem à aula 3:
BEGIN
INSERT INTO "solid_queue_jobs" ("queue_name", "class_name", "arguments", "priority",
"active_job_id", "scheduled_at", "finished_at", "concurrency_key", "created_at", "updated_at")
VALUES ('probe', 'ProbeJob', '{"job_class":"ProbeJob", ...}', 0, '213942c3-...', ...)
SAVEPOINT active_record_1
INSERT INTO "solid_queue_ready_executions" ("job_id", "queue_name", "priority", "created_at")
VALUES (72, 'probe', 0, '2026-09-01 20:00:31.479980') RETURNING "id"
RELEASE SAVEPOINT active_record_1
COMMIT
Uma transação, dois inserts. O savepoint vem do create_or_find_by!, que se protege de um job_id duplicado que o índice único em solid_queue_ready_executions.job_id rejeitaria. Uma linha de job sem linha de execução é um job que o Solid Queue nunca mais olha. Os dois inserts fazendo commit juntos é o que torna a máquina de estados confiável.
Por que a linha de execução repete queue_name e priority
Olhe a linha de ready do job mais antigo daquele retrato:
{"id" => 45, "job_id" => 45, "queue_name" => "default", "priority" => 0,
"created_at" => 2026-03-04 17:26:22.354953000 UTC +00:00}
queue_name e priority já estão em solid_queue_jobs. O SolidQueue::Execution::JobAttributes os copia para cá:
class_attribute :assumable_attributes_from_job, instance_accessor: false, default: %i[ queue_name priority ]
def assume_attributes_from_job
self.class.assumable_attributes_from_job.each do |attribute|
send("#{attribute}=", job.send(attribute))
end
end
ReadyExecution chama assumes_attributes_from_job sem nomes extras, então copia esses dois. ScheduledExecution acrescenta scheduled_at, e BlockedExecution acrescenta concurrency_key.
É desnormalização deliberada. A query que um worker roda a cada décimo de segundo, o ORDER BY priority, job_id LIMIT n da aula 2, filtra e ordena só na tabela de execução, por um índice que guarda essas colunas, e nunca faz join com solid_queue_jobs. A linha do job é lida uma vez, depois do claim, quando o trabalho começa.
Enfileirar em lote é a mesma forma, mais larga
ActiveJob.perform_all_later passa pelo Job.enqueue_all, que troca o callback por duas instruções de várias linhas dentro de uma transação:
INSERT INTO "solid_queue_jobs" (...) VALUES (...), (...), (...)
SELECT "solid_queue_jobs".* FROM "solid_queue_jobs"
WHERE "solid_queue_jobs"."active_job_id" IN ('9f7a667e-...', '43e0b7ef-...', '465f0ac7-...')
ORDER BY "solid_queue_jobs"."id" ASC
INSERT INTO "solid_queue_ready_executions" ("queue_name","priority","job_id","created_at")
VALUES ('probe', 0, 49, CURRENT_TIMESTAMP), ('probe', 0, 50, CURRENT_TIMESTAMP), ('probe', 0, 51, CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING RETURNING "id"
COMMIT
Depois vêm dois plucks, um em solid_queue_ready_executions e outro em solid_queue_blocked_executions, para saber onde cada job caiu. dispatch_all manda jobs sem chave de concorrência pelo insert em lote, e jobs com chave pelo dispatch, um de cada vez. successfully_enqueued só é marcado para jobs encontrados numa das duas tabelas.
A transação em que você não está
O adapter do Solid Queue ainda define enqueue_after_transaction_commit? devolvendo true, mas o Rails 8.1 não o consulta. Quem decide é a configuração enqueue_after_transaction_commit de cada job, com padrão false, e este app não a liga.
Ela também não tornaria o enfileiramento atômico. O curso de nível Pleno "Padrão: outbox pattern" mostrou por quê: a fila mora num segundo banco, então os dois inserts acima nunca podem dividir uma transação com a sua linha de Partners::Partner. Este curso não repete esse argumento. Ele toma a linha do job como dada e a segue.
O que este curso cobre
- Esta aula: a transação de enqueue, e por que linhas de execução repetem colunas do job.
- O claim:
FOR UPDATE SKIP LOCKED, os dois índices de polling, e o que um worker faz dez vezes por segundo. - Controles de concorrência como linhas: um semáforo com chave única, e uma execução bloqueada com expiração.
- Agendado, falho e refeito: o lote do dispatcher, o registro de falha, e por que
retry_oncria uma segunda linha de job. - Processos, heartbeats, e o que acontece com um job reivindicado quando o worker dele morre.
- Tarefas recorrentes, pausas, e a validação que se recusa a iniciar com os valores do deploy desta aplicação.
- Um job real rastreado por todas as tabelas, e as decisões de desenho que as tabelas codificam.
A aula 2 é onde linhas como aquelas 45 finalmente se movem, quando algo as consulta.
Continue com a aula 2
O claim: FOR UPDATE SKIP LOCKED e dois índices de polling
Como um worker reivindica linhas com FOR UPDATE SKIP LOCKED, ordena por prioridade e usa dois índices de polling.
Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.