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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ab4135c3f942d5333ade592af3ae92b194dd0aa6c32b8ee26d6992b8fa30e51
4
- data.tar.gz: 317b0d39134b558ffa1cabfe7f2a6b137269e95438dc736c230ed557608554db
3
+ metadata.gz: ae67ed15f7e3e684726abb005f023d769f0ee953ebce5d3f019baecb6d2a5d46
4
+ data.tar.gz: 76bfaf830d5bf571797f5fdd42794f087506eca3dd15c43ca0fc7ee6733871eb
5
5
  SHA512:
6
- metadata.gz: 70d5413c5d89753ed3c1b011d2e76b216a016df55db0dbf90d7162949912a9b4f3e7f363f5a6d9ae257468dd62de6a67358086bf1d0dfe85226a40691459fa8b
7
- data.tar.gz: c88ad248c63439a693e8d6f726cc5066a8765d628774b46ef6a5a9150ed016a6981d96040c0cb7f2f52b844e4e33a47acb3c2fef4a1771202f3bdbbcbf09e653
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, CONTEXT.md.
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
- # this checks whether a user is authenticated with devise
16
- # It looks for a token in the query parameters, or in the headers
17
- m = request.query_parameters["token"].presence || request.headers["Authorization"].split(" ").second.strip rescue nil
18
-
19
- # check for m2m token
20
- user = User.where.not(encrypted_access_token: nil).find { |u| BCrypt::Password.new(u.encrypted_access_token) == m } rescue false
21
- return user if user
22
-
23
- # check for JWT token
24
- body = (::HashWithIndifferentAccess.new(::JWT.decode(m, ::Rails.application.credentials.dig(:secret_key_base).presence||ENV["SECRET_KEY_BASE"], false)[0]) rescue nil)
25
- verified_user = env['warden'].user.presence || User.find_by(id: body[:user_id]) rescue false
26
- return verified_user if verified_user
27
-
28
- # The Token is not m2m nor JWT, fail
29
- return reject_unauthorized_connection
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
@@ -1,3 +1,3 @@
1
1
  module ThecoreBackendCommons
2
- VERSION = "3.9.0".freeze
2
+ VERSION = "3.10.0".freeze
3
3
  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.9.0
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