thecore_backend_commons 3.9.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +61 -0
- data/config/initializers/application_config.rb +1 -1
- data/config/initializers/concern_cable_connection.rb +45 -16
- data/config/initializers/sidekiq.rb +11 -0
- data/lib/thecore_backend_commons/discarded_job_logger.rb +31 -0
- data/lib/thecore_backend_commons/job_failure_policy.rb +54 -0
- data/lib/thecore_backend_commons/version.rb +1 -1
- data/lib/thecore_backend_commons.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae67ed15f7e3e684726abb005f023d769f0ee953ebce5d3f019baecb6d2a5d46
|
|
4
|
+
data.tar.gz: 76bfaf830d5bf571797f5fdd42794f087506eca3dd15c43ca0fc7ee6733871eb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1b1554f540e0b404c3544ffee0cbc2ca531c861fc7d910a00892ca7867fbcc866f93e29860626afc4d41c4da31cc783cc73d5396235d8d3eb0c9fad78d9914a7
|
|
7
|
+
data.tar.gz: a25a66bf6cefa142a62fc176576efebb259424323563726a3256f132fea54c398b777b7782f501b59ea20de37ffca37201682aafb2eaa46a4c61db31d3d39b64
|
data/README.md
CHANGED
|
@@ -159,6 +159,67 @@ overrides it. Full mechanics (idempotency, abstract/STI exclusion, `to_prepare`
|
|
|
159
159
|
|
|
160
160
|
---
|
|
161
161
|
|
|
162
|
+
## Background jobs: no retries by default
|
|
163
|
+
|
|
164
|
+
Every app using this gem runs its background jobs on Sidekiq in production, and by default:
|
|
165
|
+
|
|
166
|
+
- **a failed job is never retried** (`retry: false`): it runs once, and Sidekiq keeps it in
|
|
167
|
+
neither the retry set nor the Dead Set;
|
|
168
|
+
- **only the latest failed job is kept in Redis**: `sidekiq-failures` (from
|
|
169
|
+
`thecore_background_jobs`) stores each failed job's full payload, arguments included, in the
|
|
170
|
+
Redis `failed` set; it is capped at 1, still visible in the Sidekiq web UI's Failures tab;
|
|
171
|
+
- **every failure is written to the Rails log** by `ThecoreBackendCommons::DiscardedJobLogger`:
|
|
172
|
+
job class (or `Mailer#action`), queue, jid and error, never the arguments.
|
|
173
|
+
|
|
174
|
+
Why zero: in a Thecore app in production, mail jobs carrying a whole PDF in their arguments
|
|
175
|
+
failed with a permanent SMTP error and were retried for weeks, then kept in the Dead Set. Redis
|
|
176
|
+
grew to 23 GB until the host's OOM killer put it in a restart loop and no job ran for three days
|
|
177
|
+
(`docs/adr/0002-no-job-retries-failures-logged.md`).
|
|
178
|
+
|
|
179
|
+
### Changing the defaults on an installation
|
|
180
|
+
|
|
181
|
+
Set these in the environment of both the `backend` and the `worker` containers. On an
|
|
182
|
+
installation deployed by `thecore_devcontainer` that is the target's
|
|
183
|
+
`vendor/deploytargets/<provider>/<customer>.env`, which reaches both containers through their
|
|
184
|
+
`env_file` (`thecore_devcontainer` ADR 0002); on a hand-written compose, its `environment:`.
|
|
185
|
+
|
|
186
|
+
| Variable | Unset or blank | Accepted values | Effect |
|
|
187
|
+
| --- | --- | --- | --- |
|
|
188
|
+
| `THECORE_JOB_RETRIES` | `0` (no retries) | a whole number >= 0 | how many times Sidekiq retries a failed job, app-wide |
|
|
189
|
+
| `THECORE_FAILED_JOBS_KEPT` | `1` | a whole number >= 1 | how many failed jobs stay in the Redis `failed` set |
|
|
190
|
+
|
|
191
|
+
Any other value (`yes`, `-1`, `1.5`, ...) stops the app at boot with an error naming the
|
|
192
|
+
variable: a typo never silently turns retries back on. With retries above 0, a job that uses
|
|
193
|
+
them all ends in Sidekiq's Dead Set, which is capped by count and age but not by size.
|
|
194
|
+
|
|
195
|
+
### Changing it for one job
|
|
196
|
+
|
|
197
|
+
A job class sets its own retry count with Sidekiq's option, which wins over the app-wide value
|
|
198
|
+
in both directions (it works on ActiveJob classes and mailers' jobs alike):
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
class SyncInventoryJob < ApplicationJob
|
|
202
|
+
sidekiq_options retry: 3 # or retry: false when THECORE_JOB_RETRIES is above 0
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Don't use ActiveJob's `retry_on` for this.** It retries independently of Sidekiq: on failure
|
|
207
|
+
ActiveJob enqueues a new copy of the job, arguments included, whatever `THECORE_JOB_RETRIES`
|
|
208
|
+
says, and the copies never show up as Sidekiq retries.
|
|
209
|
+
|
|
210
|
+
### Logging failures somewhere else too
|
|
211
|
+
|
|
212
|
+
Add your own Sidekiq death handler next to the default one; both run:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
# config/initializers/sidekiq.rb in the app
|
|
216
|
+
Sidekiq.configure_server do |config|
|
|
217
|
+
config.death_handlers << ->(job, exception) { MyErrorLog.record(job["jid"], exception) }
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
162
223
|
## Invio email e configurazione SMTP
|
|
163
224
|
|
|
164
225
|
### Configurazione
|
|
@@ -13,5 +13,5 @@ Rails.application.config.action_cable.allowed_request_origins = [/http:\/\/*/, /
|
|
|
13
13
|
# and mounts Sidekiq::Web unconditionally (thecore_background_jobs), so ActiveJob should actually
|
|
14
14
|
# route through it rather than defaulting to Rails' in-process :async, which silently drops any
|
|
15
15
|
# job in flight when its process restarts. Production only: :async remains the default in
|
|
16
|
-
# development/test so neither requires a reachable Redis. See docs/adr/0001,
|
|
16
|
+
# development/test so neither requires a reachable Redis. See docs/adr/0001, GLOSSARY.md.
|
|
17
17
|
Rails.application.config.active_job.queue_adapter = :sidekiq if Rails.env.production?
|
|
@@ -11,22 +11,51 @@ module CableConnectionConcern
|
|
|
11
11
|
|
|
12
12
|
protected
|
|
13
13
|
|
|
14
|
-
def find_verified_user
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
def find_verified_user
|
|
15
|
+
# An already-authenticated Devise/Warden session (e.g. RailsAdmin in the browser) is
|
|
16
|
+
# trusted as-is, before looking at any token.
|
|
17
|
+
warden_user = env["warden"]&.user
|
|
18
|
+
return warden_user if warden_user
|
|
19
|
+
|
|
20
|
+
# Looks for a token in the query parameters, or in the "Authorization" header.
|
|
21
|
+
m = request.query_parameters["token"].presence ||
|
|
22
|
+
request.headers["Authorization"].to_s.split(" ").second&.strip
|
|
23
|
+
return reject_unauthorized_connection if m.blank?
|
|
24
|
+
|
|
25
|
+
# Machine-to-machine token (bcrypt-compared against each user's stored token). Guarded by a
|
|
26
|
+
# column check so this generic gem also works in a host without m2m tokens (and so its own
|
|
27
|
+
# dummy app, whose users table has no such column, doesn't abort the transaction).
|
|
28
|
+
if User.column_names.include?("encrypted_access_token")
|
|
29
|
+
m2m_user = User.where.not(encrypted_access_token: nil)
|
|
30
|
+
.find { |u| BCrypt::Password.new(u.encrypted_access_token) == m } rescue nil
|
|
31
|
+
return m2m_user if m2m_user
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# JWT: the signature MUST be verified, exactly like every REST request. Decoding with the
|
|
35
|
+
# verification disabled (the previous behaviour) let a forged token carrying an arbitrary
|
|
36
|
+
# user_id authenticate as any user over the WebSocket — and ActivityLogChannel streams every
|
|
37
|
+
# record change, so that was a full read bypass. See docs/adr and the issue tracker.
|
|
38
|
+
body = decode_verified_jwt(m)
|
|
39
|
+
jwt_user = User.find_by(id: body[:user_id]) if body && body[:user_id]
|
|
40
|
+
return jwt_user if jwt_user
|
|
41
|
+
|
|
42
|
+
# Neither a valid m2m nor a valid JWT token: refuse the connection.
|
|
43
|
+
reject_unauthorized_connection
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Decodes and VERIFIES a JWT. Reuses model_driven_api's JsonWebToken when present, so the
|
|
47
|
+
# WebSocket shares the REST API's signing key, key-rotation and single-session handling;
|
|
48
|
+
# falls back to a signature-verifying decode so this gem still works on its own. Returns nil
|
|
49
|
+
# on any invalid, forged or expired token.
|
|
50
|
+
def decode_verified_jwt(token)
|
|
51
|
+
if defined?(::JsonWebToken)
|
|
52
|
+
::JsonWebToken.decode(token)
|
|
53
|
+
else
|
|
54
|
+
secret = ::Rails.application.credentials.dig(:secret_key_base).presence || ENV["SECRET_KEY_BASE"]
|
|
55
|
+
::HashWithIndifferentAccess.new(::JWT.decode(token, secret, true, algorithm: "HS256")[0])
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
30
59
|
end
|
|
31
60
|
end
|
|
32
61
|
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# What a failed background job leaves behind, app-wide, from the container's environment:
|
|
2
|
+
# THECORE_JOB_RETRIES (default 0, no retries) and THECORE_FAILED_JOBS_KEPT (default 1, the latest
|
|
3
|
+
# failure in the sidekiq-failures set); an invalid value refuses to boot (JobFailurePolicy).
|
|
4
|
+
# Why: docs/adr/0002-no-job-retries-failures-logged.md.
|
|
5
|
+
ThecoreBackendCommons::JobFailurePolicy.apply!
|
|
6
|
+
|
|
7
|
+
# Every failed job leaves one line in the Rails log (DiscardedJobLogger); apps append their own
|
|
8
|
+
# handlers next to it. Required, not autoloaded: see docs/adr/0002-no-job-retries-failures-logged.md.
|
|
9
|
+
Sidekiq.configure_server do |config|
|
|
10
|
+
config.death_handlers << ThecoreBackendCommons::DiscardedJobLogger unless config.death_handlers.include?(ThecoreBackendCommons::DiscardedJobLogger)
|
|
11
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module ThecoreBackendCommons
|
|
2
|
+
# Generic Sidekiq death handler, registered by config/initializers/sidekiq.rb: with retries off
|
|
3
|
+
# Sidekiq drops a failed job without keeping it anywhere, so this writes one line to the Rails log
|
|
4
|
+
# per failure. Only the job's identity goes in: class (or Mailer#action for a mail job), queue, jid
|
|
5
|
+
# and the error, never the arguments, which can be huge or sensitive (docs/adr/0002). Apps add their own handlers next to this one (`config.death_handlers << MyHandler`
|
|
6
|
+
# inside `Sidekiq.configure_server`), e.g. to record failures in their own integration log.
|
|
7
|
+
module DiscardedJobLogger
|
|
8
|
+
MAX_MESSAGE_LENGTH = 500
|
|
9
|
+
|
|
10
|
+
def self.call(job, exception)
|
|
11
|
+
error = "#{exception.class}: #{exception.message}".truncate(MAX_MESSAGE_LENGTH)
|
|
12
|
+
Rails.logger.error(
|
|
13
|
+
"Background job #{name_of(job)} failed and was discarded (no retry left): " \
|
|
14
|
+
"queue=#{job["queue"]} jid=#{job["jid"]} error=#{error}"
|
|
15
|
+
)
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
Rails.logger.error("DiscardedJobLogger could not record a failed job: #{e.class}: #{e.message}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.name_of(job)
|
|
21
|
+
wrapped = (job["wrapped"] || job["class"]).to_s
|
|
22
|
+
first = Array(job["args"]).first
|
|
23
|
+
arguments = first.is_a?(Hash) ? Array(first["arguments"]) : []
|
|
24
|
+
return wrapped unless wrapped == "ActionMailer::MailDeliveryJob" && arguments.size >= 2
|
|
25
|
+
|
|
26
|
+
"#{arguments[0]}##{arguments[1]}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private_class_method :name_of
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module ThecoreBackendCommons
|
|
2
|
+
# What happens to a failed background job, app-wide, read from the container's environment by
|
|
3
|
+
# config/initializers/sidekiq.rb (docs/adr/0002-no-job-retries-failures-logged.md):
|
|
4
|
+
#
|
|
5
|
+
# - THECORE_JOB_RETRIES: how many times Sidekiq retries a failed job. Unset or blank = 0, i.e.
|
|
6
|
+
# `retry: false` (not `retry: 0`, which would still send the job to the Dead Set).
|
|
7
|
+
# - THECORE_FAILED_JOBS_KEPT: how many failed jobs sidekiq-failures keeps in the Redis `failed`
|
|
8
|
+
# set, payload and arguments included (Sidekiq.failures_max_count). Unset or blank = 1, the
|
|
9
|
+
# latest, still shown in the Failures tab.
|
|
10
|
+
#
|
|
11
|
+
# Any other value refuses to boot: a typo must never silently re-enable retries or keep every
|
|
12
|
+
# failed payload. A job class still overrides the retry count with `sidekiq_options retry: N`.
|
|
13
|
+
# Required, not autoloaded: see the ADR.
|
|
14
|
+
module JobFailurePolicy
|
|
15
|
+
RETRIES_VARIABLE = "THECORE_JOB_RETRIES".freeze
|
|
16
|
+
KEPT_VARIABLE = "THECORE_FAILED_JOBS_KEPT".freeze
|
|
17
|
+
DEFAULT_RETRIES = 0
|
|
18
|
+
DEFAULT_KEPT = 1
|
|
19
|
+
|
|
20
|
+
class InvalidValue < StandardError; end
|
|
21
|
+
|
|
22
|
+
# Sets Sidekiq's app-wide default retry option and the sidekiq-failures cap.
|
|
23
|
+
def self.apply!
|
|
24
|
+
Sidekiq.default_job_options = Sidekiq.default_job_options.merge("retry" => sidekiq_retry_option)
|
|
25
|
+
Sidekiq.failures_max_count = failed_jobs_kept
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The value for Sidekiq's `retry` option: false for no retries, else the count.
|
|
29
|
+
def self.sidekiq_retry_option
|
|
30
|
+
retries.zero? ? false : retries
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.retries
|
|
34
|
+
whole_number_from_env(RETRIES_VARIABLE, default: DEFAULT_RETRIES, minimum: 0, expected: "a whole number >= 0")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.failed_jobs_kept
|
|
38
|
+
whole_number_from_env(KEPT_VARIABLE, default: DEFAULT_KEPT, minimum: 1, expected: "a whole number >= 1")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The whole number in the environment variable, default when unset or blank; raises InvalidValue
|
|
42
|
+
# on anything else or below minimum.
|
|
43
|
+
def self.whole_number_from_env(variable, default:, minimum:, expected:)
|
|
44
|
+
raw = ENV.fetch(variable, nil)
|
|
45
|
+
return default if raw.nil? || raw.strip.empty?
|
|
46
|
+
|
|
47
|
+
value = raw.strip
|
|
48
|
+
return value.to_i if value.match?(/\A\d+\z/) && value.to_i >= minimum
|
|
49
|
+
|
|
50
|
+
raise InvalidValue, "#{variable}=#{raw.inspect} is not valid: use #{expected}, or leave it unset (default #{default})"
|
|
51
|
+
end
|
|
52
|
+
private_class_method :whole_number_from_env
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -19,6 +19,8 @@ require "thecore_backend_commons/smtp_config"
|
|
|
19
19
|
require "thecore_backend_commons/smtp_tester"
|
|
20
20
|
require "thecore_backend_commons/push_notification_service"
|
|
21
21
|
require "thecore_backend_commons/default_module_registry"
|
|
22
|
+
require "thecore_backend_commons/discarded_job_logger"
|
|
23
|
+
require "thecore_backend_commons/job_failure_policy"
|
|
22
24
|
|
|
23
25
|
module ThecoreBackendCommons
|
|
24
26
|
# Deep-merges +dest+ into +src+ (mutating and returning +src+), extending existing arrays
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: thecore_backend_commons
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gabriele Tassoni
|
|
@@ -234,6 +234,7 @@ files:
|
|
|
234
234
|
- config/initializers/default_module_registry.rb
|
|
235
235
|
- config/initializers/extension_nil.rb
|
|
236
236
|
- config/initializers/extension_string.rb
|
|
237
|
+
- config/initializers/sidekiq.rb
|
|
237
238
|
- config/locales/en.devise.custom.yml
|
|
238
239
|
- config/locales/en.yml
|
|
239
240
|
- config/locales/it.devise.custom.yml
|
|
@@ -247,7 +248,9 @@ files:
|
|
|
247
248
|
- lib/tasks/thecore_backend_commons_tasks.rake
|
|
248
249
|
- lib/thecore_backend_commons.rb
|
|
249
250
|
- lib/thecore_backend_commons/default_module_registry.rb
|
|
251
|
+
- lib/thecore_backend_commons/discarded_job_logger.rb
|
|
250
252
|
- lib/thecore_backend_commons/engine.rb
|
|
253
|
+
- lib/thecore_backend_commons/job_failure_policy.rb
|
|
251
254
|
- lib/thecore_backend_commons/push_notification_service.rb
|
|
252
255
|
- lib/thecore_backend_commons/smtp_config.rb
|
|
253
256
|
- lib/thecore_backend_commons/smtp_tester.rb
|