Skip to content

[Deferred] Propagate ActiveSupport::CurrentAttributes across fiber boundaries#280

Open
jsxs0 wants to merge 1 commit intorage-rb:mainfrom
jsxs0:feat-current-attributes-propagation
Open

[Deferred] Propagate ActiveSupport::CurrentAttributes across fiber boundaries#280
jsxs0 wants to merge 1 commit intorage-rb:mainfrom
jsxs0:feat-current-attributes-propagation

Conversation

@jsxs0
Copy link
Copy Markdown
Contributor

@jsxs0 jsxs0 commented Apr 20, 2026

Summary

Propagates ActiveSupport::CurrentAttributes across Rage::Deferred's fiber boundary so background tasks see the same Current.* values as the request that enqueued them.

Problem

While tracing log-context propagation in #267 (SSE) and #274 (Deferred), I kept ending up at the same place. Rage::Deferred::Queue#schedule spawns a fresh fiber via Fiber.schedule (queue.rb:48), and every Fiber[:key] from the parent fiber gets wiped. Context.build catches logger tags (index 4) and logger context (index 5), but ActiveSupport::CurrentAttributes gets silently dropped at exactly the same boundary. Which is surprising, because Rage explicitly wires CurrentAttributes to fiber-local storage via IsolatedExecutionState.isolation_level = :fiber at ext/setup.rb.

Concretely: set Current.user, Current.tenant, or Current.request_id in a request, enqueue a deferred task, and the task runs with all of those nil. Apps relying on CurrentAttributes for multi-tenancy, audit trails, or trace context break invisibly the moment work crosses into the queue.

Approach

Extends the Context array with index 7, storing a snapshot of each ActiveSupport::CurrentAttributes subclass's attributes at enqueue time. Values are .dup'd so a subsequent Current.reset in the parent fiber cannot mutate what was captured.

In the task fiber, __perform reads the snapshot and restores values via direct writer calls. The ensure block only resets subclasses that were actually restored. Resetting every CurrentAttributes descendant on every task completion would pointlessly fire before_reset/after_reset callbacks across the whole app. The same rescue-per-subclass pattern from the #248 connection-cleanup work applies, so one broken subclass cannot take down the whole task.

Why snapshot-and-restore rather than patching CurrentAttributes' internal storage

I spent some time considering a version that patched AS internals so attribute writes routed through a dedicated Fiber[:key], mirroring how Rage already treats logger tags. It would have been elegant. But monkey-patching AS internals is fragile across Rails versions and hostile to the next maintainer. Rails itself solves this exact problem with snapshot-and-restore in ActiveJob::CurrentAttributes. If it's good enough for Rails' own background-job system, it's good enough here, and the code reads as a mirror of an established pattern rather than a novel hack.

What this gets us:

  • Graceful no-op when ActiveSupport isn't loaded (guarded at capture time, no runtime cost on every enqueue)
  • Task fibers reused by Iodine's worker pool don't leak Current.* values between tasks
  • Works without any Rails-version-specific branches

Scope

Intentionally scoped to the Rage::Deferred fiber boundary only. The same pattern applies to Rage::SSE::Application#start_stream (sse/application.rb:60, which I was inside recently for #264 and #267) and Rage::FiberScheduler#fiber for user-spawned fibers. I'll open those as separate PRs so each can be reviewed on its own merits.

Test plan

  • bundle exec rspec spec/deferred/: 146 examples, 0 failures
  • bundle exec rspec spec/telemetry/: 77 examples, 0 failures, 2 pending (pre-existing external-test skips, unrelated)
  • bundle exec rspec spec/sse/: 79 examples, 0 failures

Five new examples on Context.capture_current_attributes: AS not loaded, AS loaded with no subclasses, subclasses with attributes (capture + dup verification), empty-attribute subclasses. Five on Task#__perform: restore before perform, reset after perform, reset on perform raise, graceful continuation when a subclass writer raises, and a no-op path when AS isn't loaded.

Notes

  • Enqueue-time overhead benchmark isn't included yet. I expect well under 1% given typical apps have 3 to 10 CurrentAttributes subclasses with small attribute counts, but I want to measure before claiming a number. Will add in a follow-up commit.
  • Context array layout: the new index 7 is backward-compatible with existing 7-element contexts persisted in the disk WAL. They read back nil at get_current_attributes and no-op cleanly through restore/reset.

Context

In my day job I run SSE systems for industrial IoT monitoring, which is how I ended up reading Rage's SSE and Deferred internals in the first place. The bug this fixes is one I've shipped around by hand in other frameworks, carrying device_id and tenant through async hops manually because the framework wouldn't. Nice to be able to fix it at the right layer here.

cc @rsamoilov

@jsxs0 jsxs0 force-pushed the feat-current-attributes-propagation branch from 5024ff4 to b4b27ed Compare April 20, 2026 06:22
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.

1 participant