Skip to content

Fix clear cache in notifications maily digest - #11

Merged
microstudi merged 4 commits into
mainfrom
fix_notifications_maily_digest_bug
Jun 8, 2026
Merged

microstudi merged 4 commits into
mainfrom
fix_notifications_maily_digest_bug

Conversation

@ElviaBth

@ElviaBth ElviaBth commented Jun 4, 2026 •

Copy link
Copy Markdown
Member

🎩 What? Why?
Fix the bug where translations are not always applied after clear cache, especially in Decidiamo emails such as daily digest and newsletter, requiring the button to be clicked again.

📷 Screenshots

♥️ Thank you!

Summary by CodeRabbit

  • Bug Fixes

    • Prevented cross-thread/background-job leakage in the term customizer by isolating and clearing per-request/job loader state; translations now safely fall back when no loader is present.
  • Tests

    • Added concurrency tests to verify per-thread isolation and translation safety.
  • Chores

    • Updated lint configuration to exclude additional test directories.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f13b1dc3-4140-4e23-a390-f287f8704fba

📥 Commits

Reviewing files that changed from the base of the PR and between 1fe3163 and 2081935.

📒 Files selected for processing (1)
  • lib/decidim/term_customizer/i18n_backend.rb
💤 Files with no reviewable changes (1)
  • lib/decidim/term_customizer/i18n_backend.rb

📝 Walkthrough

Walkthrough

TermCustomizer.loader is made thread-local (singleton getter/setter using Thread.current), engine notifications clear the loader after controller actions and jobs, I18nBackend translations guard against missing loader, and concurrency specs plus RuboCop exclusion for spec/lib/**/* were added.

Changes

Thread-local loader isolation

Layer / File(s) Summary
Thread-local loader singleton methods
lib/decidim/term_customizer.rb
TermCustomizer.loader and loader= are implemented as singleton methods that read/write a thread-local variable via Thread.current instead of a module attr_accessor.
Engine lifecycle cleanup hooks
lib/decidim/term_customizer/engine.rb
Adds ActiveSupport::Notifications subscribers that set TermCustomizer.loader = nil after process_action.action_controller and perform.active_job.
Thread isolation tests and RuboCop configuration
spec/lib/decidim/term_customizer/term_customizer_loader_spec.rb, .rubocop.yml
Adds concurrency tests verifying per-thread loader isolation and that I18nBackend#translations does not leak across threads. Updates .rubocop.yml to exclude spec/lib/**/* from linting.
I18nBackend translations guard
lib/decidim/term_customizer/i18n_backend.rb
translations no longer returns an early cached @translations before checking TermCustomizer.loader; returns {} when loader is absent and otherwise assigns from TermCustomizer.loader.translations_hash.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops through threads so tight,
Each carries its own loader bright.
No shared state shall block the way,
Lifecycle clears at end of day.
🐰 Threads tidy, translations play.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix clear cache in notifications maily digest' is directly related to the PR's objective of fixing a cache clearing bug affecting email translations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix_notifications_maily_digest_bug

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ElviaBth
ElviaBth marked this pull request as ready for review June 4, 2026 13:08
@ElviaBth
ElviaBth requested a review from microstudi June 4, 2026 13:08

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
spec/lib/decidim/term_customizer/term_customizer_loader_spec.rb (2)

53-63: ⚡ Quick win

Replace brittle sleep timing with proper synchronization.

Same issue as the first test: sleep-based coordination is non-deterministic. Use a Concurrent::CyclicBarrier to ensure both threads set their loaders before calling backend.translations.

⏱️ Proposed fix using CyclicBarrier
+    barrier = Concurrent::CyclicBarrier.new(2)
+
     t1 = Thread.new do
       Decidim::TermCustomizer.loader = loader_org1
-      sleep 0.05
+      barrier.wait
       result_thread1 = backend.translations
     end
 
     t2 = Thread.new do
-      sleep 0.02
       Decidim::TermCustomizer.loader = loader_org2
+      barrier.wait
       result_thread2 = backend.translations
     end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@spec/lib/decidim/term_customizer/term_customizer_loader_spec.rb` around lines
53 - 63, The test uses sleep-based timing around Thread.new to coordinate
setting Decidim::TermCustomizer.loader and calling backend.translations, which
is brittle; replace the sleeps with a Concurrent::CyclicBarrier (or other thread
barrier) so both threads set their respective loaders (loader_org1 and
loader_org2) and then wait on the barrier before invoking backend.translations,
ensuring deterministic synchronization for result_thread1 and result_thread2;
locate the Thread.new blocks and the uses of Decidim::TermCustomizer.loader and
backend.translations to implement the barrier wait/leave calls.

20-30: ⚡ Quick win

Replace brittle sleep timing with proper synchronization.

The test uses sleep to coordinate threads, which is non-deterministic and can cause flaky tests under load. Use the Concurrent::CyclicBarrier pattern to ensure deterministic synchronization.

⏱️ Proposed fix using CyclicBarrier
+    barrier = Concurrent::CyclicBarrier.new(2)
+
     t1 = Thread.new do
       Decidim::TermCustomizer.loader = loader_org1
-      sleep 0.05
+      barrier.wait
       thread1_loader = Decidim::TermCustomizer.loader
     end
 
     t2 = Thread.new do
-      sleep 0.02
       Decidim::TermCustomizer.loader = loader_org2
+      barrier.wait
       thread2_loader = Decidim::TermCustomizer.loader
     end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@spec/lib/decidim/term_customizer/term_customizer_loader_spec.rb` around lines
20 - 30, Replace the brittle sleep-based coordination in the two Thread.new
blocks with a Concurrent::CyclicBarrier to deterministically synchronize the
threads: create a barrier (e.g. barrier = Concurrent::CyclicBarrier.new(2))
before spawning t1/t2, then in each thread call barrier.wait at the point where
you currently use sleep to ensure both threads reach the same synchronization
point before performing Decidim::TermCustomizer.loader = loader_org1 /
loader_org2 and before reading thread1_loader / thread2_loader; ensure you
reference the existing variables loader_org1 and loader_org2 and add require
'concurrent' if not already present.
.rubocop.yml (1)

13-13: ⚡ Quick win

Avoid blanket exclusion of spec/lib from linting.

Excluding the entire spec/lib directory from RuboCop is overly broad. The new spec file contains issues (dead code at lines 14-18, long test methods) that linting would typically catch.

Instead of excluding the directory, either:

  1. Fix the linting violations in the spec file, or
  2. Use inline rubocop:disable comments for specific unavoidable violations
🔍 Alternative: selective disabling

If specific cops are problematic for this spec file, disable them inline:

# rubocop:disable RSpec/MultipleExpectations
it "is isolated per thread" do
  # test code
end
# rubocop:enable RSpec/MultipleExpectations

Or remove the directory exclusion and fix violations as they arise.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.rubocop.yml at line 13, Remove the blanket exclusion "spec/lib/**/*" from
.rubocop.yml and either fix the lint issues in the new spec file (remove the
dead code around lines 14-18 and break up long test methods) or, if specific
cops must be suppressed, add targeted inline disables in that spec (e.g., use
rubocop:disable for RSpec/MultipleExpectations or other offending cops around
just the problematic examples) rather than excluding the entire directory.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/decidim/term_customizer.rb`:
- Around line 40-46: The Thread.current thread-local accessor methods loader and
loader= on TermCustomizer can leak across pooled threads; ensure you clear the
loader at the end of each request by subscribing to the action controller
completion event (e.g. hook after start_processing) and setting
TermCustomizer.loader = nil in that subscriber (subscribe to
"process_action.action_controller"), and audit callers of TermCustomizer.loader
to handle nil defensively (background jobs, console, tests) so no code assumes a
non-nil loader.

In `@spec/lib/decidim/term_customizer/term_customizer_loader_spec.rb`:
- Around line 14-18: Remove the unused Concurrent::CyclicBarrier instantiation
and its surrounding begin/rescue block in the term_customizer_loader_spec.rb
spec; locate the code that creates Concurrent::CyclicBarrier.new(2) (the begin
... rescue StandardError ... end block) and delete those lines so the spec no
longer contains dead, unused synchronization setup—no replacement is needed
unless the spec actually requires synchronization, in which case add a clear,
used synchronization primitive in place of the removed block.

---

Nitpick comments:
In @.rubocop.yml:
- Line 13: Remove the blanket exclusion "spec/lib/**/*" from .rubocop.yml and
either fix the lint issues in the new spec file (remove the dead code around
lines 14-18 and break up long test methods) or, if specific cops must be
suppressed, add targeted inline disables in that spec (e.g., use rubocop:disable
for RSpec/MultipleExpectations or other offending cops around just the
problematic examples) rather than excluding the entire directory.

In `@spec/lib/decidim/term_customizer/term_customizer_loader_spec.rb`:
- Around line 53-63: The test uses sleep-based timing around Thread.new to
coordinate setting Decidim::TermCustomizer.loader and calling
backend.translations, which is brittle; replace the sleeps with a
Concurrent::CyclicBarrier (or other thread barrier) so both threads set their
respective loaders (loader_org1 and loader_org2) and then wait on the barrier
before invoking backend.translations, ensuring deterministic synchronization for
result_thread1 and result_thread2; locate the Thread.new blocks and the uses of
Decidim::TermCustomizer.loader and backend.translations to implement the barrier
wait/leave calls.
- Around line 20-30: Replace the brittle sleep-based coordination in the two
Thread.new blocks with a Concurrent::CyclicBarrier to deterministically
synchronize the threads: create a barrier (e.g. barrier =
Concurrent::CyclicBarrier.new(2)) before spawning t1/t2, then in each thread
call barrier.wait at the point where you currently use sleep to ensure both
threads reach the same synchronization point before performing
Decidim::TermCustomizer.loader = loader_org1 / loader_org2 and before reading
thread1_loader / thread2_loader; ensure you reference the existing variables
loader_org1 and loader_org2 and add require 'concurrent' if not already present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d5334fc-37f9-41d7-b348-8f13d19ec0d7

📥 Commits

Reviewing files that changed from the base of the PR and between 67bfd1e and e6342b7.

📒 Files selected for processing (4)
  • .rubocop.yml
  • lib/decidim/term_customizer.rb
  • lib/decidim/term_customizer/i18n_backend.rb
  • spec/lib/decidim/term_customizer/term_customizer_loader_spec.rb
💤 Files with no reviewable changes (1)
  • lib/decidim/term_customizer/i18n_backend.rb

Comment on lines +40 to +46
def loader
Thread.current.thread_variable_get(:term_customizer_loader)
end

def loader=(loader)
Thread.current.thread_variable_set(:term_customizer_loader, loader)
end

@coderabbitai coderabbitai Bot Jun 4, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Critical: Thread pooling can leak loaders between requests.

In production web servers with thread pools (Puma, Passenger), threads are reused across requests. Thread-local variables persist for the thread's lifetime, so the loader from one request can leak into subsequent requests on the same thread if the engine hook doesn't fire or fails.

Add explicit cleanup at request completion to prevent cross-request contamination:

# In engine.rb after the start_processing hook
ActiveSupport::Notifications.subscribe "process_action.action_controller" do
  TermCustomizer.loader = nil
end

Additionally, consider defensive nil handling in callers, as the loader may be nil in background jobs, console, or tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/decidim/term_customizer.rb` around lines 40 - 46, The Thread.current
thread-local accessor methods loader and loader= on TermCustomizer can leak
across pooled threads; ensure you clear the loader at the end of each request by
subscribing to the action controller completion event (e.g. hook after
start_processing) and setting TermCustomizer.loader = nil in that subscriber
(subscribe to "process_action.action_controller"), and audit callers of
TermCustomizer.loader to handle nil defensively (background jobs, console,
tests) so no code assumes a non-nil loader.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about after an active_job, should we have some similar defensive action ('perform_start.active_job')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how the loader is set for jobs (job_context_class) and any existing hooks
rg -n "job_context" --type=rb -A3 -B3
rg -n "loader" --type=rb -A2 -B2
rg -n "active_job\|perform\|around_perform\|before_perform\|after_perform" --type=rb -A3 -B3

Length of output: 581


🏁 Script executed:

#!/bin/bash
rg -n "job_context" -A3 -B3
rg -n "loader" -A2 -B2
rg -n "around_perform\|before_perform\|after_perform\|perform_start\|perform_end" -A3 -B3

Length of output: 16025



@microstudi Yes, adding cleanup after job execution is a good defensive measure. A couple of points worth noting:

  1. The right event for cleanup is perform.active_job (the finish event), not perform_start.active_job (which is already used to set the loader). The engine already does:
# engine.rb — sets loader at job start
ActiveSupport::Notifications.subscribe "perform_start.active_job" do |..., data|
  context = TermCustomizer.job_context_class.new(data)
  TermCustomizer.loader = Loader.new(resolver)
  # ...
end

The cleanup counterpart would be:

# Clear loader after job completes so the thread-local doesn't linger
ActiveSupport::Notifications.subscribe "perform.active_job" do
  TermCustomizer.loader = nil
end
  1. Severity is lower than for web requests — because perform_start.active_job already overwrites the loader at the beginning of every job, a stale loader from a previous job will be replaced before any translations are looked up. The residual risk is more about code that runs outside a job on a pooled thread (e.g., a fiber or spawned thread inside a job, or the thread being used in an unexpected context after the job finishes).

  2. Controller cleanup symmetry — since the same pattern is recommended for process_action.action_controller, doing the same for perform.active_job keeps the two execution contexts consistent and makes the thread-local lifecycle explicit and bounded.

So yes, it's worth adding the perform.active_job cleanup subscriber alongside the existing perform_start.active_job subscriber in engine.rb.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seem like a very unlikely case. Why is it different the case for active job? even with the web case, isn't the event "process_action.action_controller" going to be executed every new requests? independently of the thread processing it, so the loader is restarted?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@microstudi You're right, and the two cases are symmetric. My original "critical" label was overstated.

Here's the accurate picture:

  • Web requests: A start_processing.action_controller (or similar) subscriber already sets a fresh loader at the beginning of every request. So when thread A picks up request N+1, the start hook fires first and overwrites whatever stale loader was left by request N — before any translation lookup can happen. No observable leak.
  • Jobs: Exactly the same — perform_start.active_job already overwrites the loader at job start. A stale loader from a previous job on the same thread is replaced before any code in the new job sees it.

So the process_action.action_controller and perform.active_job cleanup hooks this PR adds are defensive hygiene, not a critical safety requirement. Their main benefits are:

  1. Memory: releasing the loader object (and its cached DB data) so it can be GC'd sooner rather than sitting on the thread until the next request starts.
  2. Edge cases: protecting code that might run on the thread outside a normal request/job lifecycle (e.g. a fiber spawned after job completion, a middleware that does translation lookups post-action, etc.).

The leakage scenario would only matter if a translation lookup happened on a thread between the old request's teardown and the new request's start hook — which doesn't occur in normal Puma/Sidekiq usage.

Apologies for flagging this as critical; it's a nice-to-have cleanup, not a required fix.

Comment thread spec/lib/decidim/term_customizer/term_customizer_loader_spec.rb Outdated

@microstudi microstudi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems difficult to really asses that this is working fine without testing in production.
Meanwhile a couple of simple comments

Comment thread lib/decidim/term_customizer/i18n_backend.rb
Comment thread spec/lib/decidim/term_customizer/term_customizer_loader_spec.rb Outdated
@ElviaBth ElviaBth self-assigned this Jun 4, 2026
Comment on lines +40 to +46
def loader
Thread.current.thread_variable_get(:term_customizer_loader)
end

def loader=(loader)
Thread.current.thread_variable_set(:term_customizer_loader, loader)
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seem like a very unlikely case. Why is it different the case for active job? even with the web case, isn't the event "process_action.action_controller" going to be executed every new requests? independently of the thread processing it, so the loader is restarted?

@microstudi
microstudi merged commit 493dafb into main Jun 8, 2026
3 checks passed
@microstudi
microstudi deleted the fix_notifications_maily_digest_bug branch August 31, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants