From fb08e73b5c2c6f0c19ac6a6a2b6aeaefaf0321ee Mon Sep 17 00:00:00 2001 From: Hirley Date: Mon, 31 Aug 2026 21:28:00 -0300 Subject: [PATCH] Adota Solid Queue como fila persistente em producao MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #77. Nao havia queue_adapter configurado em lugar nenhum, entao valia o default do Rails: :async, que guarda a fila na memoria do processo web. Todo restart, deploy ou OOM descartava em silencio o que ainda nao tinha rodado — inclusive as retentativas de webhook agendadas com backoff pelo PR anterior, que por definicao ficam pendentes por algum tempo. Producao passa a usar :solid_queue, gravando nas tabelas solid_queue_* do proprio PostgreSQL. Desenvolvimento segue com :async, pra que `bin/rails server` sozinho continue funcionando sem exigir um segundo processo; teste segue com :test. Mesmo banco da aplicacao, e nao o banco separado que o instalador da gem assume. Multiplos bancos obrigariam a reescrever config/database.yml inteiro, incluindo o caminho de DATABASE_URL que o Railway injeta, pra resolver um problema de escala que este projeto nao tem. Por isso o db/queue_schema.rb da gem virou uma migration comum. O worker e um servico separado no docker-compose.yml, rodando bin/jobs. Sobe so depois de o web ficar saudavel: e o entrypoint do web que aplica as migrations, e o worker precisa das tabelas existindo. Ele nao roda migration nenhuma de proposito — dois processos preparando o mesmo banco ao mesmo tempo e corrida, nao redundancia. Build e ambiente saem de ancoras YAML compartilhadas, pra que web e worker nao divirjam na primeira variavel nova que alguem esquecer de copiar. Detalhes que ficaram registrados no codigo: * o HEALTHCHECK da imagem faz curl na porta 3000, o que nao faz sentido pro worker — desligado no servico, senao ele ficaria eternamente "unhealthy" por nao responder algo que nem deveria responder; * config/recurring.yml existe vazio so pra o worker parar de avisar que nao encontrou o arquivo, o que viraria ruido permanente no log; * as duas migrations do Solid Queue sao schema da gem copiado, nao codigo nosso: entram na exclusao do RuboCop pelo mesmo criterio ja aplicado ao db/schema.rb. Reformata-las faria o proximo `solid_queue:update` virar conflito. O Gemfile.lock ganhou a plataforma x86_64-linux, efeito de resolver as gems dentro do container Linux. E uma melhoria: o lockfile passa a descrever a plataforma onde a aplicacao de fato roda, e o `bundle lock --add-platform` do Dockerfile continua ali como rede de seguranca. Verificado com o compose de verdade, e nao so com specs: job enfileirado pelo web com o worker PARADO sobreviveu a um restart do web e foi executado quando o worker voltou — exatamente o que o :async nao fazia. Co-Authored-By: Claude Opus 5 --- .env.example | 5 + .rubocop.yml | 8 + Gemfile | 5 + Gemfile.lock | 106 ++++++++++- README.md | 22 ++- bin/jobs | 9 + config/environments/production.rb | 14 ++ config/queue.yml | 24 +++ config/recurring.yml | 14 ++ ...0260831000001_create_solid_queue_tables.rb | 176 ++++++++++++++++++ ...260831000002_add_batches_to_solid_queue.rb | 39 ++++ db/schema.rb | 160 +++++++++++++++- docker-compose.yml | 89 ++++++--- 13 files changed, 640 insertions(+), 31 deletions(-) create mode 100644 bin/jobs create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 db/migrate/20260831000001_create_solid_queue_tables.rb create mode 100644 db/migrate/20260831000002_add_batches_to_solid_queue.rb diff --git a/.env.example b/.env.example index 048b008..59245c7 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,8 @@ APP_HOST= # Opcional — tamanho do pool de conexões do banco (ver config/database.yml). RAILS_MAX_THREADS=5 + +# Opcional — número de processos do worker de jobs (Solid Queue; ver +# config/queue.yml e o serviço "worker" no docker-compose.yml). Um só +# basta para o volume atual; existe para escalar sem editar arquivo. +JOB_CONCURRENCY=1 diff --git a/.rubocop.yml b/.rubocop.yml index c29522a..1fb4283 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,6 +9,14 @@ AllCops: - "db/schema.rb" - "bin/**/*" - "vendor/**/*" + # Migrations do Solid Queue: são o schema da gem copiado para cá (ver + # o comentário em CreateSolidQueueTables), não código deste projeto. + # Reformatá-las para o nosso estilo faria o próximo + # `bin/rails solid_queue:update` virar um conflito a resolver à mão, + # e o ganho seria zero — ninguém edita esses arquivos. Mesmo critério + # do db/schema.rb acima. As migrations escritas por nós continuam + # sendo checadas normalmente. + - "db/migrate/*_solid_queue*.rb" # O projeto documenta decisões em comentários contextuais (o "porquê" de # cada escolha), não em RDoc por classe — exigir um comentário fixo de diff --git a/Gemfile b/Gemfile index 13831d0..1701572 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,11 @@ gem 'pg', '~> 1.5' gem 'propshaft' gem 'puma', '>= 6.0' gem 'rails', '~> 8.1' +# Fila persistente de Active Job, no próprio PostgreSQL (ver +# config/environments/production.rb e a seção "Webhooks de saída" do +# README). Substitui o adapter :async, que guardava a fila na memória do +# processo web e perdia tudo que estivesse pendente num restart. +gem 'solid_queue' gem 'stimulus-rails' gem 'turbo-rails' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] diff --git a/Gemfile.lock b/Gemfile.lock index bc2d7da..3078ceb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,6 +75,7 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + ast (2.4.3) base64 (0.3.0) bcrypt (3.1.22) bigdecimal (4.1.2) @@ -100,11 +101,16 @@ GEM drb (2.2.3) erb (6.0.7) erubi (1.13.1) + et-orbi (1.4.1) + tzinfo factory_bot (6.6.0) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) + fugit (1.13.0) + et-orbi (~> 1.4) + raabro (~> 1.4) globalid (1.4.0) activesupport (>= 6.1) haml (7.3.0) @@ -130,6 +136,8 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) json (2.21.2) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) logger (1.7.0) loofah (2.25.2) crass (~> 1.0.2) @@ -141,6 +149,7 @@ GEM net-pop net-smtp marcel (1.2.1) + matrix (0.4.3) mini_mime (1.1.5) minitest (6.0.6) drb (~> 2.0) @@ -158,9 +167,23 @@ GEM nio4r (2.7.5) nokogiri (1.19.4-x64-mingw-ucrt) racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) orm_adapter (0.5.0) + parallel (2.1.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pdf-core (0.9.0) + pg (1.6.3-x64-mingw-ucrt) + pg (1.6.3-x86_64-linux) pp (0.6.4) prettyprint + prawn (2.4.0) + pdf-core (~> 0.9.0) + ttfunk (~> 1.7) + prawn-table (0.2.2) + prawn (>= 1.3.0, < 3.0.0) prettyprint (0.2.0) prism (1.9.0) propshaft (1.3.2) @@ -169,6 +192,7 @@ GEM rack puma (8.0.2) nio4r (~> 2.0) + raabro (1.5.0) racc (1.8.1) rack (3.2.7) rack-session (2.1.2) @@ -212,7 +236,12 @@ GEM thor (~> 1.0, >= 1.2.2) tsort (>= 0.2) zeitwerk (~> 2.6) + rainbow (3.1.1) rake (13.4.2) + ransack (4.4.1) + activerecord (>= 7.2) + activesupport (>= 7.2) + i18n rbs (4.1.3) logger prism (>= 1.6.0) @@ -222,6 +251,7 @@ GEM prism (>= 1.6.0) rbs (>= 4.0.0) tsort + regexp_parser (2.12.0) reline (0.7.0) io-console (~> 0.5) responders (3.2.0) @@ -244,10 +274,41 @@ GEM rspec-mocks (>= 3.13.0, < 5.0.0) rspec-support (>= 3.13.0, < 5.0.0) rspec-support (3.13.7) + rubocop (1.90.0) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-rails (2.37.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) + ruby-progressbar (1.13.0) securerandom (0.4.1) shoulda-matchers (8.0.1) activesupport (>= 7.2) - sqlite3 (2.9.6-x64-mingw-ucrt) + solid_queue (1.7.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) stimulus-rails (1.3.4) railties (>= 6.0.0) temple (0.10.6) @@ -255,6 +316,7 @@ GEM tilt (2.8.0) timeout (0.6.1) tsort (0.2.0) + ttfunk (1.7.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -262,6 +324,9 @@ GEM concurrent-ruby (~> 1.0) tzinfo-data (1.2026.3) tzinfo (>= 1.0.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) warden (1.2.9) @@ -278,6 +343,7 @@ GEM PLATFORMS x64-mingw-ucrt + x86_64-linux DEPENDENCIES bootsnap @@ -287,13 +353,21 @@ DEPENDENCIES factory_bot_rails haml-rails importmap-rails + matrix + pg (~> 1.5) + prawn + prawn-table propshaft puma (>= 6.0) rails (~> 8.1) rails-controller-testing + ransack rspec-rails + rubocop + rubocop-rails + rubocop-rspec shoulda-matchers - sqlite3 (>= 2.1) + solid_queue stimulus-rails turbo-rails tzinfo-data @@ -312,6 +386,7 @@ CHECKSUMS activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd @@ -330,8 +405,10 @@ CHECKSUMS drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.1) sha256=007e2685b1d873415a7587ef889336c6dcdf8a47fc9c9a63547582b21660a11b factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89 factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 + fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 haml (7.3.0) sha256=4383cb90bb0b525d4025dcb9721fec085edda1c43525043fc327c80127f6cf37 haml-rails (3.1.0) sha256=4366474d973e4b9d5326cb216f2073150cfeff4c2a3c07fadb49a490ffdf610b @@ -340,10 +417,13 @@ CHECKSUMS io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b @@ -353,12 +433,21 @@ CHECKSUMS net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 nokogiri (1.19.4-x64-mingw-ucrt) sha256=051da97b8eccfdb5444fed40246a35e10d7298b9efe759b4cd25455ea04c587e + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pdf-core (0.9.0) sha256=4f368b2f12b57ec979872d4bf4bd1a67e8648e0c81ab89801431d2fc89f4e0bb + pg (1.6.3-x64-mingw-ucrt) sha256=cdff974cbde6935e07b8f5cc3c2af7d320bc5263bde06acaae08302194ebf5e1 + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prawn (2.4.0) sha256=82062744f7126c2d77501da253a154271790254dfa8c309b8e52e79bc5de2abd + prawn-table (0.2.2) sha256=336d46e39e003f77bf973337a958af6a68300b941c85cb22288872dc2b36addb prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 @@ -369,9 +458,12 @@ CHECKSUMS rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 railties (8.1.3.1) sha256=2388a232579a00cefea4487de66c8553c3408c1300abdc6cf1799d86ffb04487 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + ransack (4.4.1) sha256=6aeaac36fc19088570e10da1044e6cfd88c740e20f871b84566fd30e32b7a63d rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4 rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d @@ -379,18 +471,26 @@ CHECKSUMS rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e + rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 shoulda-matchers (8.0.1) sha256=5dbb46e5765b9da225111b085e0819e8c8a121ff94bba430a153eb1ea2c60288 - sqlite3 (2.9.6-x64-mingw-ucrt) sha256=1f2b88f417fd0a8c1d5ef19c7e817d8b9c61bee6e33b6b36255fb6e40148e6f8 + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 temple (0.10.6) sha256=0f2ed8a885a81ef5b13b1d99a02948e36da34f2212390f0a0228bfd0412c400a thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 tilt (2.8.0) sha256=ba472eb2716fe1e04112d6d219a9dae938ec09a6a1e2ad3ecc7922e79bde3721 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + ttfunk (1.7.0) sha256=2370ba484b1891c70bdcafd3448cfd82a32dd794802d81d720a64c15d3ef2a96 turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b tzinfo-data (1.2026.3) sha256=478fbc5356f13c1004cf8372b1336f3dad4055c96340fc4c881a3738da8cf7f9 + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 diff --git a/README.md b/README.md index 9a1992d..980bc02 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,17 @@ Pra subir localmente, um único comando, sem nenhum passo manual antes: docker compose up --build ``` -Isso sobe a imagem de produção **e** um serviço `db` (PostgreSQL) localmente, na porta `3000`, com os dados do banco persistidos num volume nomeado (sobrevivem a `docker compose down`, mas não a `docker compose down -v`). Não é um ambiente de desenvolvimento com hot-reload — para isso, continue usando `bundle install && rails server` (apontando pro serviço `db` ou pra um Postgres local), como na seção anterior. +Isso sobe **três** serviços, com os dados do banco persistidos num volume nomeado (sobrevivem a `docker compose down`, mas não a `docker compose down -v`): + +| Serviço | O que é | +|---|---| +| `db` | PostgreSQL | +| `web` | a aplicação (Puma), na porta `3000` | +| `worker` | o processador de jobs (Solid Queue, `bin/jobs`) | + +O `worker` roda a mesma imagem do `web`, e sobe só depois de o `web` ficar **saudável** — é o entrypoint do `web` que aplica as migrations, então o worker precisa esperar as tabelas existirem. Ele também não roda migration nenhuma de propósito: dois processos preparando o mesmo banco ao mesmo tempo é corrida, não redundância. + +Não é um ambiente de desenvolvimento com hot-reload — para isso, continue usando `bundle install && rails server` (apontando pro serviço `db` ou pra um Postgres local), como na seção anterior. Lá o adapter de jobs é o `:async`, que roda em thread no próprio processo, então não é preciso subir um segundo processo para desenvolver. Duas coisas acontecem sozinhas nesse modo local, e **nenhuma das duas deve valer num deploy de verdade**: @@ -259,7 +269,13 @@ Além do Telegram, o **admin** pode cadastrar webhooks genéricos em `/webhooks` **Como funciona:** - `WebhookSubscription` (`app/models/webhook_subscription.rb`) guarda a URL e os eventos escolhidos (array nativo do Postgres). Um webhook pode ser pausado (`active: false`) sem precisar excluir o cadastro. -- `WebhookDispatcher` (`app/services/webhook_dispatcher.rb`) encontra as assinaturas ativas que escutam o evento e enfileira `WebhookDeliveryJob` pra cada uma — em background (adapter `:async` padrão do Rails; este projeto não tem Sidekiq/Solid Queue configurado), pra não travar a request original no tempo de resposta de um serviço de terceiro. +- `WebhookDispatcher` (`app/services/webhook_dispatcher.rb`) encontra as assinaturas ativas que escutam o evento e enfileira `WebhookDeliveryJob` pra cada uma — em background, pra não travar a request original no tempo de resposta de um serviço de terceiro. + + Em **produção** a fila é o **Solid Queue**, gravando nas tabelas `solid_queue_*` do próprio PostgreSQL da aplicação (ver `config/environments/production.rb` e `config/queue.yml`). Isso exige um **processo separado** rodando `bin/jobs` — é o serviço `worker` do `docker-compose.yml`. Sem ele os jobs ficam enfileirados no banco esperando, em vez de sumir. + + O default do Rails, `:async`, guarda a fila na **memória do processo web**: todo restart, deploy ou OOM descartava em silêncio o que ainda não tinha rodado — incluindo as retentativas de webhook agendadas com backoff, que por definição ficam pendentes por algum tempo. Em **desenvolvimento** o `:async` continua valendo, pra que `bin/rails server` sozinho siga funcionando sem exigir um segundo processo; em **teste**, o adapter é o `:test`. + + Optamos pelo mesmo banco da aplicação, e não pelo banco separado que o instalador do Solid Queue assume: o projeto tem um PostgreSQL só, e adotar múltiplos bancos obrigaria a reescrever o `config/database.yml` inteiro — incluindo o caminho de `DATABASE_URL`, que é o que o Railway injeta — para resolver um problema de escala que não existe aqui. - `WebhookDelivery` (`app/services/webhook_delivery.rb`) faz o `POST` de fato, com timeout curto (5s). O retorno dele é o que decide se o job tenta de novo, e a distinção é deliberada: | Situação | Resultado | O job… | @@ -270,7 +286,7 @@ Além do Telegram, o **admin** pode cadastrar webhooks genéricos em `/webhooks` Esgotadas as tentativas, `WebhookDeliveryJob` registra a desistência no log em nível `error` — a entrega perdida deixa rastro em vez de sumir. Um erro inesperado (bug nosso, não instabilidade do outro lado) também não consome tentativas: é logado como `error` e encerra, porque repetir cinco vezes não conserta e só atrasa o diagnóstico. - Vale a ressalva: como o adapter ainda é o `:async` em memória, as tentativas reagendadas também se perdem num restart do processo. O retry cobre a instabilidade curta, que é a maioria; durabilidade de verdade depende de trocar o backend da fila. + Em produção as tentativas reagendadas ficam gravadas junto com o job, no Solid Queue — sobrevivem a um restart do web e são retomadas quando o worker volta. Em desenvolvimento, com o `:async`, ainda se perdem. - **Proteção contra SSRF**: `PublicHttpTarget` (`app/services/public_http_target.rb`) resolve o host e recusa endereços de rede privada/local (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` e as faixas IPv6 equivalentes), pra que um webhook não vire um jeito de fazer a aplicação bater num serviço interno da própria rede. A checagem roda **duas vezes, de propósito**: - no **cadastro/edição** (validação de `WebhookSubscription`), só pra dar o erro no formulário enquanto o admin ainda está na tela; - de novo na **hora da entrega** (`WebhookDelivery`), imediatamente antes de conectar — e é essa que protege de fato. Sozinha, a validação do cadastro não segura *DNS rebinding*: como a entrega acontece quando um evento dispara (possivelmente dias depois), bastaria cadastrar um host que resolve pra um IP público e trocar o registro DNS com calma. O IP verificado na entrega é passado direto pro `Net::HTTP` (`ipaddr:`), então o endereço checado é exatamente o endereço conectado — sem intervalo entre a checagem e o uso. O host original continua valendo pro cabeçalho `Host`, SNI e validação do certificado TLS. diff --git a/bin/jobs b/bin/jobs new file mode 100644 index 0000000..03e5546 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby + +# Sobe o supervisor do Solid Queue (dispatcher + workers). É o processo +# separado do web: ver o serviço "worker" no docker-compose.yml e a seção +# "Webhooks de saída" do README. +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/config/environments/production.rb b/config/environments/production.rb index a09eeef..d3de740 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -9,6 +9,20 @@ config.active_storage.service = :local + # Fila persistente, nas tabelas solid_queue_* do próprio banco (ver + # config/queue.yml e a migration CreateSolidQueueTables). + # + # O default do Rails é :async, que guarda a fila na MEMÓRIA do processo + # web: todo restart, deploy ou OOM descartava em silêncio o que ainda + # não tinha rodado — inclusive as retentativas de webhook agendadas com + # backoff, que por definição estão pendentes por algum tempo. + # + # Exige um processo separado rodando `bin/jobs` (ver o serviço "worker" + # no docker-compose.yml). Sem ele os jobs ficam enfileirados no banco + # esperando, em vez de sumir — o que é o ponto: a falha passa a ser + # visível e recuperável. + config.active_job.queue_adapter = :solid_queue + config.log_tags = [:request_id] config.logger = ActiveSupport::TaggedLogging.logger($stdout) config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info') diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 0000000..7db3348 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,24 @@ +# Configuração do Solid Queue (ver Gemfile e a seção "Webhooks de saída" +# do README). Só vale onde o adapter é :solid_queue — hoje, produção. +# +# Um worker só, com 3 threads, é folgado para o volume deste projeto: a +# única coisa que roda em background é a entrega de webhooks. JOB_CONCURRENCY +# existe para escalar sem mexer no arquivo, se um dia isso mudar. +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 0000000..83567d5 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,14 @@ +# Tarefas recorrentes do Solid Queue. Está vazio de propósito: sem o +# arquivo, o worker sobe reclamando no log ("provided configuration file +# does not exist"), o que vira ruído permanente numa saída que a gente +# quer conseguir ler. +# +# A única tarefa periódica deste projeto hoje é a notificação de atraso +# (`bin/rails demandas:notificar_atrasos`), que roda por um scheduler +# externo — ver lib/tasks/telegram_notifications.rake e a seção +# "Notificação de atraso via Telegram" do README. Migrá-la para cá é +# possível, mas exigiria antes resolver a idempotência da tarefa, senão +# duas execuções concorrentes duplicariam o aviso. +production: {} +development: {} +test: {} diff --git a/db/migrate/20260831000001_create_solid_queue_tables.rb b/db/migrate/20260831000001_create_solid_queue_tables.rb new file mode 100644 index 0000000..71c5bb3 --- /dev/null +++ b/db/migrate/20260831000001_create_solid_queue_tables.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +# Tabelas do Solid Queue, no MESMO banco da aplicacao. +# +# O instalador da gem assume um banco separado (config/queue.yml + +# db/queue_schema.rb, carregado com db:schema:load). Aqui o conteudo +# daquele schema virou uma migration normal, de proposito: este projeto +# tem um Postgres so, e adotar multiplos bancos obrigaria a reescrever +# config/database.yml inteiro (incluindo o caminho de DATABASE_URL, que +# e o que o Railway injeta) para resolver um problema de escala que +# nao existe aqui. +# +# Gerado a partir de solid_queue-1.7.0; ao atualizar a gem, use +# bin/rails solid_queue:update para as migrations incrementais. +class CreateSolidQueueTables < ActiveRecord::Migration[8.1] + def change + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "batch_id" + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "batch_id" ], name: "index_solid_queue_jobs_on_batch_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + create_table "solid_queue_batches", force: :cascade do |t| + t.string "active_job_batch_id" + t.string "description" + t.text "on_finish" + t.text "on_success" + t.text "on_failure" + t.text "metadata" + t.integer "total_jobs", default: 0, null: false + t.integer "completed_jobs", default: 0, null: false + t.integer "failed_jobs", default: 0, null: false + t.datetime "enqueued_at" + t.datetime "finished_at" + t.datetime "failed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_batch_id" ], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true + t.index [ "finished_at" ], name: "index_solid_queue_batches_on_finished_at" + end + + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "batch_id", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_batch_executions_on_job_id", unique: true + t.index [ "batch_id" ], name: "index_solid_queue_batch_executions_on_batch_id" + end + + add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade + add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + end +end diff --git a/db/migrate/20260831000002_add_batches_to_solid_queue.rb b/db/migrate/20260831000002_add_batches_to_solid_queue.rb new file mode 100644 index 0000000..432f5a2 --- /dev/null +++ b/db/migrate/20260831000002_add_batches_to_solid_queue.rb @@ -0,0 +1,39 @@ +class AddBatchesToSolidQueue < ActiveRecord::Migration[7.1] + def change + # Fresh installs create all of this with the base schema, so skip + # anything that already exists + add_column :solid_queue_jobs, :batch_id, :bigint, if_not_exists: true + add_index :solid_queue_jobs, :batch_id, if_not_exists: true + + create_table :solid_queue_batches, if_not_exists: true do |t| + t.string :active_job_batch_id + t.string :description + t.text :on_finish + t.text :on_success + t.text :on_failure + t.text :metadata + t.integer :total_jobs, default: 0, null: false + t.integer :completed_jobs, default: 0, null: false + t.integer :failed_jobs, default: 0, null: false + t.datetime :enqueued_at + t.datetime :finished_at + t.datetime :failed_at + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + + t.index :active_job_batch_id, unique: true + t.index :finished_at + end + + create_table :solid_queue_batch_executions, if_not_exists: true do |t| + t.bigint :job_id, null: false + t.bigint :batch_id, null: false + t.datetime :created_at, null: false + + t.index :job_id, unique: true + t.index :batch_id + t.foreign_key :solid_queue_batches, column: :batch_id, on_delete: :cascade + t.foreign_key :solid_queue_jobs, column: :job_id, on_delete: :cascade + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b9fde30..6303a30 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_08_28_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_08_31_000002) do create_table "demandas", force: :cascade do |t| t.datetime "atraso_notificado_em" t.datetime "created_at", null: false @@ -24,6 +24,156 @@ t.index ["user_id"], name: "index_demandas_on_user_id" end + create_table "solid_queue_batch_executions", force: :cascade do |t| + t.bigint "batch_id", null: false + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.index ["batch_id"], name: "index_solid_queue_batch_executions_on_batch_id" + t.index ["job_id"], name: "index_solid_queue_batch_executions_on_job_id", unique: true + end + + create_table "solid_queue_batches", force: :cascade do |t| + t.string "active_job_batch_id" + t.integer "completed_jobs", default: 0, null: false + t.datetime "created_at", null: false + t.string "description" + t.datetime "enqueued_at" + t.datetime "failed_at" + t.integer "failed_jobs", default: 0, null: false + t.datetime "finished_at" + t.text "metadata" + t.text "on_failure" + t.text "on_finish" + t.text "on_success" + t.integer "total_jobs", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["active_job_batch_id"], name: "index_solid_queue_batches_on_active_job_batch_id", unique: true + t.index ["finished_at"], name: "index_solid_queue_batches_on_finished_at" + end + + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.string "concurrency_key", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.bigint "process_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "active_job_id" + t.text "arguments" + t.bigint "batch_id" + t.string "class_name", null: false + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["batch_id"], name: "index_solid_queue_jobs_on_batch_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.text "metadata" + t.string "name", null: false + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.datetime "run_at", null: false + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.text "arguments" + t.string "class_name" + t.string "command", limit: 2048 + t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false + t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false + t.boolean "static", default: true, null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false + t.datetime "updated_at", null: false + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true + end + create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.string "email", default: "", null: false @@ -53,5 +203,13 @@ end add_foreign_key "demandas", "users" + add_foreign_key "solid_queue_batch_executions", "solid_queue_batches", column: "batch_id", on_delete: :cascade + add_foreign_key "solid_queue_batch_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "webhook_subscriptions", "users" end diff --git a/docker-compose.yml b/docker-compose.yml index 9164e56..c86da8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,49 @@ # entrypoint gera uma chave efêmera pra execução; ver bin/docker-entrypoint): # docker compose up --build # +# São três serviços: o banco, o web (Puma) e o worker (Solid Queue). O +# worker é processo separado de propósito — é o que faz um job sobreviver +# a um restart do web, que era justamente o que o adapter :async não +# fazia. Ver config/environments/production.rb. +# # Os dados do Postgres ficam num volume nomeado (tk_pgdata), então # sobrevivem a `docker compose down` (mas não a `docker compose down -v`). + +# Build e ambiente ficam em âncoras porque web e worker precisam ser +# idênticos: rodam o mesmo código, contra o mesmo banco. Duas listas +# separadas divergiriam na primeira variável nova que alguém esquecesse +# de copiar — e o sintoma seria um job se comportando diferente da tela. +x-app-build: &app-build + context: . + args: + RUBY_VERSION: ${RUBY_VERSION:-4.0.6} + +x-app-env: &app-env + # Sem valor default de propósito: quando esta variável chega vazia, + # bin/docker-entrypoint gera uma chave efêmera só para a execução + # (ver o comentário lá, que explica por que uma chave fixa aqui era + # perigosa). Em produção de verdade, defina via .env — ver README, + # seção "Docker". + # + # Vazia, web e worker geram chaves DIFERENTES. Não afeta os jobs de + # hoje (os argumentos são valores simples, que o Active Job não + # assina), mas é mais um motivo para definir a variável num deploy de + # verdade. + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-} + # Servido em http://localhost — sem TLS, então o redirect do + # force_ssl deixaria a demo inacessível. Num deploy de verdade essa + # variável não é definida, e o default de production.rb (ligado) + # vale. Ver config/environments/production.rb. + FORCE_SSL: ${FORCE_SSL:-false} + DB_HOST: db + DB_PORT: 5432 + DB_USERNAME: ${DB_USERNAME:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_NAME: ${DB_NAME:-task_keeper_api_production} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} + APP_HOST: ${APP_HOST:-localhost} + services: db: image: postgres:16-alpine @@ -26,38 +67,38 @@ services: retries: 10 web: - build: - context: . - args: - RUBY_VERSION: ${RUBY_VERSION:-4.0.6} + build: *app-build ports: - "${PORT:-3000}:3000" - environment: - # Sem valor default de propósito: quando esta variável chega vazia, - # bin/docker-entrypoint gera uma chave efêmera só para a execução - # (ver o comentário lá, que explica por que uma chave fixa aqui era - # perigosa). Em produção de verdade, defina via .env — ver README, - # seção "Docker". - SECRET_KEY_BASE: ${SECRET_KEY_BASE:-} - # Servido em http://localhost — sem TLS, então o redirect do - # force_ssl deixaria a demo inacessível. Num deploy de verdade essa - # variável não é definida, e o default de production.rb (ligado) - # vale. Ver config/environments/production.rb. - FORCE_SSL: ${FORCE_SSL:-false} - DB_HOST: db - DB_PORT: 5432 - DB_USERNAME: ${DB_USERNAME:-postgres} - DB_PASSWORD: ${DB_PASSWORD:-postgres} - DB_NAME: ${DB_NAME:-task_keeper_api_production} - TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} - RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} - APP_HOST: ${APP_HOST:-localhost} + environment: *app-env volumes: - tk_log:/rails/log depends_on: db: condition: service_healthy + worker: + build: *app-build + command: ["./bin/jobs"] + environment: *app-env + volumes: + - tk_log:/rails/log + # O HEALTHCHECK do Dockerfile faz um curl na porta 3000 — faz sentido + # pro web, não pro worker, que não serve HTTP nenhum. Sem desligar, + # ele ficaria eternamente "unhealthy" por não responder algo que nem + # deveria responder. + healthcheck: + disable: true + # Espera o web ficar SAUDÁVEL, e não só o banco: é o entrypoint do + # web que roda `db:prepare` (ver bin/docker-entrypoint). Subir antes + # disso faria o worker procurar as tabelas solid_queue_* que ainda + # não existem. É também o motivo de o worker não rodar migration + # nenhuma: dois processos preparando o mesmo banco ao mesmo tempo é + # corrida, não redundância. + depends_on: + web: + condition: service_healthy + volumes: tk_pgdata: tk_log: