diff --git a/api/benchmarks/context_bench.rb b/api/benchmarks/context_bench.rb new file mode 100644 index 0000000000..185d393567 --- /dev/null +++ b/api/benchmarks/context_bench.rb @@ -0,0 +1,1001 @@ +# frozen_string_literal: true + +# Copyright The OpenTelemetry Authors +# +# SPDX-License-Identifier: Apache-2.0 + +require 'benchmark/ipsa' +require 'concurrent-ruby' +require 'opentelemetry' + +# Manages context on a per-fiber basis +class FiberLocalVarContext + EMPTY_ENTRIES = {}.freeze + VAR = Concurrent::FiberLocalVar.new { [] } + private_constant :EMPTY_ENTRIES, :VAR + + DetachError = Class.new(StandardError) + + class << self + def current + stack.last || ROOT + end + + def attach(context) + s = stack + s.push(context) + s.size + end + + def detach(token) + s = stack + calls_matched = (token == s.size) + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless calls_matched + + s.pop + calls_matched + end + + def with_current(ctx) + token = attach(ctx) + yield ctx + ensure + detach(token) + end + + def with_value(key, value) + ctx = current.set_value(key, value) + token = attach(ctx) + yield ctx, value + ensure + detach(token) + end + + def with_values(values) + ctx = current.set_values(values) + token = attach(ctx) + yield ctx, values + ensure + detach(token) + end + + def value(key) + current.value(key) + end + + def clear + stack.clear + end + + def empty + new(EMPTY_ENTRIES) + end + + private + + def stack + VAR.value + end + end + + def initialize(entries) + @entries = entries.freeze + end + + def value(key) + @entries[key] + end + + alias [] value + + def set_value(key, value) + new_entries = @entries.dup + new_entries[key] = value + FiberLocalVarContext.new(new_entries) + end + + def set_values(values) # rubocop:disable Naming/AccessorMethodName: + FiberLocalVarContext.new(@entries.merge(values)) + end + + ROOT = empty.freeze +end + +Fiber.attr_accessor :opentelemetry_context + +# Manages context on a per-fiber basis +class FiberAttributeContext + EMPTY_ENTRIES = {}.freeze + private_constant :EMPTY_ENTRIES + + DetachError = Class.new(StandardError) + + class << self + def current + stack.last || ROOT + end + + def attach(context) + s = stack + s.push(context) + s.size + end + + def detach(token) + s = stack + calls_matched = (token == s.size) + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless calls_matched + + s.pop + calls_matched + end + + def with_current(ctx) + token = attach(ctx) + yield ctx + ensure + detach(token) + end + + def with_value(key, value) + ctx = current.set_value(key, value) + token = attach(ctx) + yield ctx, value + ensure + detach(token) + end + + def with_values(values) + ctx = current.set_values(values) + token = attach(ctx) + yield ctx, values + ensure + detach(token) + end + + def value(key) + current.value(key) + end + + def clear + stack.clear + end + + def empty + new(EMPTY_ENTRIES) + end + + private + + def stack + Fiber.current.opentelemetry_context ||= [] + end + end + + def initialize(entries) + @entries = entries.freeze + end + + def value(key) + @entries[key] + end + + alias [] value + + def set_value(key, value) + new_entries = @entries.dup + new_entries[key] = value + FiberAttributeContext.new(new_entries) + end + + def set_values(values) # rubocop:disable Naming/AccessorMethodName: + FiberAttributeContext.new(@entries.merge(values)) + end + + ROOT = empty.freeze +end + +# Manages context on a per-fiber basis +class LinkedListContext + EMPTY_ENTRIES = {}.freeze + STACK_KEY = :__linked_list_context_storage__ + + # @api private + class Token + attr_reader :context, :next_token + + def initialize(context, next_token) + @context = context + @next_token = next_token + end + end + + private_constant :EMPTY_ENTRIES, :STACK_KEY, :Token + + DetachError = Class.new(StandardError) + + class << self + def current + Thread.current[STACK_KEY]&.context || ROOT + end + + def attach(context) + next_token = Thread.current[STACK_KEY] + token = Token.new(context, next_token) + Thread.current[STACK_KEY] = token + token + end + + def detach(token) + current = Thread.current[STACK_KEY] + calls_matched = (token == current) + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless calls_matched + + Thread.current[STACK_KEY] = current&.next_token + calls_matched + end + + def with_current(ctx) + token = attach(ctx) + yield ctx + ensure + detach(token) + end + + def with_value(key, value) + ctx = current.set_value(key, value) + token = attach(ctx) + yield ctx, value + ensure + detach(token) + end + + def with_values(values) + ctx = current.set_values(values) + token = attach(ctx) + yield ctx, values + ensure + detach(token) + end + + def value(key) + current.value(key) + end + + def clear + Thread.current[STACK_KEY] = nil + end + + def empty + new(EMPTY_ENTRIES) + end + end + + def initialize(entries) + @entries = entries.freeze + end + + def value(key) + @entries[key] + end + + alias [] value + + def set_value(key, value) + new_entries = @entries.dup + new_entries[key] = value + LinkedListContext.new(new_entries) + end + + def set_values(values) # rubocop:disable Naming/AccessorMethodName: + LinkedListContext.new(@entries.merge(values)) + end + + ROOT = empty.freeze +end + +# Manages context on a per-fiber basis +class FiberLocalLinkedListContext < Hash + EMPTY_ENTRIES = {}.freeze + STACK_KEY = :__fiber_local_linked_list_context_storage__ + + # @api private + class Token + attr_reader :context, :next_token + + def initialize(context, next_token) + @context = context + @next_token = next_token + end + end + + private_constant :EMPTY_ENTRIES, :STACK_KEY, :Token + + DetachError = Class.new(StandardError) + + class << self + def current + Fiber[STACK_KEY]&.context || ROOT + end + + def attach(context) + Fiber[STACK_KEY] = Token.new(context, Fiber[STACK_KEY]) + end + + def detach(token) + current = Fiber[STACK_KEY] + calls_matched = (token == current) + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless calls_matched + + Fiber[STACK_KEY] = current&.next_token + calls_matched + end + + def with_current(ctx) + token = attach(ctx) + yield ctx + ensure + detach(token) + end + + def with_value(key, value) + ctx = current.set_value(key, value) + token = attach(ctx) + yield ctx, value + ensure + detach(token) + end + + def with_values(values) + ctx = current.set_values(values) + token = attach(ctx) + yield ctx, values + ensure + detach(token) + end + + def value(key) + current.value(key) + end + + def clear + Fiber[STACK_KEY] = nil + end + + def empty + new(EMPTY_ENTRIES) + end + end + + def initialize(entries) + super.merge!(entries) + end + + alias value [] + + def set_value(key, value) + new_entries = dup + new_entries[key] = value + new_entries + end + + def set_values(values) # rubocop:disable Naming/AccessorMethodName: + merge(values) + end + + ROOT = empty.freeze +end + +# Manages context on a per-fiber basis +class ArrayContext + EMPTY_ENTRIES = {}.freeze + STACK_KEY = :__array_context_storage__ + private_constant :EMPTY_ENTRIES, :STACK_KEY + + DetachError = Class.new(StandardError) + + class << self + def current + stack.last || ROOT + end + + def attach(context) + s = stack + s.push(context) + s.size + end + + def detach(token) + s = stack + calls_matched = (token == s.size) + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless calls_matched + + s.pop + calls_matched + end + + def with_current(ctx) + token = attach(ctx) + yield ctx + ensure + detach(token) + end + + def with_value(key, value) + ctx = current.set_value(key, value) + token = attach(ctx) + yield ctx, value + ensure + detach(token) + end + + def with_values(values) + ctx = current.set_values(values) + token = attach(ctx) + yield ctx, values + ensure + detach(token) + end + + def value(key) + current.value(key) + end + + def clear + stack.clear + end + + def empty + new(EMPTY_ENTRIES) + end + + private + + def stack + Thread.current[STACK_KEY] ||= [] + end + end + + def initialize(entries) + @entries = entries.freeze + end + + def value(key) + @entries[key] + end + + alias [] value + + def set_value(key, value) + new_entries = @entries.dup + new_entries[key] = value + ArrayContext.new(new_entries) + end + + def set_values(values) # rubocop:disable Naming/AccessorMethodName: + ArrayContext.new(@entries.merge(values)) + end + + ROOT = empty.freeze +end + +# Manages context on a per-fiber basis +class FiberLocalArrayContext + EMPTY_ENTRIES = {}.freeze + STACK_KEY = :__fiber_local_array_context_storage__ + + # NOTE: This is cool, but is isn't safe for concurrent use because it allows the + # owner to modify the stack after it has been shared with another fiber. + class Stack < Array + def self.current + s = Fiber[STACK_KEY] ||= new + s.correct_owner! + end + + def initialize + super + @owner = Fiber.current + end + + def correct_owner! + if @owner != Fiber.current + Fiber[STACK_KEY] = self.class.new.replace(self) + else + self + end + end + end + + private_constant :EMPTY_ENTRIES, :STACK_KEY, :Stack + + DetachError = Class.new(StandardError) + + class << self + def current + Stack.current.last || ROOT + end + + def attach(context) + s = Stack.current + s.push(context) + s.size + end + + def detach(token) + s = Stack.current + calls_matched = (token == s.size) + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless calls_matched + + s.pop + calls_matched + end + + def with_current(ctx) + s = Stack.current + s.push(ctx) + token = s.size + yield ctx + ensure + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless token == s.size + s.pop + end + + def with_value(key, value) + s = Stack.current + ctx = (s.last || ROOT).set_value(key, value) + s.push(ctx) + token = s.size + yield ctx, value + ensure + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless token == s.size + s.pop + end + + def with_values(values) + s = Stack.current + ctx = (s.last || ROOT).set_values(values) + s.push(ctx) + token = s.size + yield ctx, values + ensure + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless token == s.size + s.pop + end + + def value(key) + current.value(key) + end + + def clear + Stack.current.clear + end + + def empty + new(EMPTY_ENTRIES) + end + end + + def initialize(entries) + @entries = entries.freeze + end + + def value(key) + @entries[key] + end + + alias [] value + + def set_value(key, value) + new_entries = @entries.dup + new_entries[key] = value + FiberLocalArrayContext.new(new_entries) + end + + def set_values(values) # rubocop:disable Naming/AccessorMethodName: + FiberLocalArrayContext.new(@entries.merge(values)) + end + + ROOT = empty.freeze +end + +# Manages context on a per-fiber basis +class ImmutableArrayContext + EMPTY_ENTRIES = {}.freeze + STACK_KEY = :__immutable_array_context_storage__ + private_constant :EMPTY_ENTRIES, :STACK_KEY + + DetachError = Class.new(StandardError) + + class << self + def current + stack.last || ROOT + end + + def attach(context) + new_stack = stack + [context] + Thread.current[STACK_KEY] = new_stack + new_stack.size + end + + def detach(token) + s = stack + calls_matched = (token == s.size) + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless calls_matched + + Thread.current[STACK_KEY] = s[...-1] || [] + calls_matched + end + + def with_current(ctx) + token = attach(ctx) + yield ctx + ensure + detach(token) + end + + def with_value(key, value) + ctx = current.set_value(key, value) + token = attach(ctx) + yield ctx, value + ensure + detach(token) + end + + def with_values(values) + ctx = current.set_values(values) + token = attach(ctx) + yield ctx, values + ensure + detach(token) + end + + def value(key) + current.value(key) + end + + def clear + stack.clear + end + + def empty + new(EMPTY_ENTRIES) + end + + private + + def stack + Thread.current[STACK_KEY] ||= [] + end + end + + def initialize(entries) + @entries = entries.freeze + end + + def value(key) + @entries[key] + end + + alias [] value + + def set_value(key, value) + new_entries = @entries.dup + new_entries[key] = value + ImmutableArrayContext.new(new_entries) + end + + def set_values(values) # rubocop:disable Naming/AccessorMethodName: + ImmutableArrayContext.new(@entries.merge(values)) + end + + ROOT = empty.freeze +end + +# Manages context on a per-fiber basis +class FiberLocalImmutableArrayContext + EMPTY_ENTRIES = {}.freeze + STACK_KEY = :__fiber_local_immutable_array_context_storage__ + private_constant :EMPTY_ENTRIES, :STACK_KEY + + DetachError = Class.new(StandardError) + + class << self + def current + stack.last || ROOT + end + + def attach(context) + new_stack = stack + [context] + Fiber[STACK_KEY] = new_stack + new_stack.size + end + + def detach(token) + s = stack + calls_matched = (token == s.size) + OpenTelemetry.handle_error(exception: DetachError.new('calls to detach should match corresponding calls to attach.')) unless calls_matched + + Fiber[STACK_KEY] = s[...-1] || [] + calls_matched + end + + def with_current(ctx) + token = attach(ctx) + yield ctx + ensure + detach(token) + end + + def with_value(key, value) + ctx = current.set_value(key, value) + token = attach(ctx) + yield ctx, value + ensure + detach(token) + end + + def with_values(values) + ctx = current.set_values(values) + token = attach(ctx) + yield ctx, values + ensure + detach(token) + end + + def value(key) + current.value(key) + end + + def clear + stack.clear + end + + def empty + new(EMPTY_ENTRIES) + end + + private + + def stack + Fiber[STACK_KEY] ||= [] + end + end + + def initialize(entries) + @entries = entries.freeze + end + + def value(key) + @entries[key] + end + + alias [] value + + def set_value(key, value) + new_entries = @entries.dup + new_entries[key] = value + FiberLocalImmutableArrayContext.new(new_entries) + end + + def set_values(values) # rubocop:disable Naming/AccessorMethodName: + FiberLocalImmutableArrayContext.new(@entries.merge(values)) + end + + ROOT = empty.freeze +end + +Benchmark.ipsa do |x| + x.report 'FiberAttributeContext.with_value' do + FiberAttributeContext.with_value('key', 'value') { |ctx, _| ctx } + end + + x.report 'LinkedListContext.with_value' do + LinkedListContext.with_value('key', 'value') { |ctx, _| ctx } + end + + x.report 'ArrayContext.with_value' do + ArrayContext.with_value('key', 'value') { |ctx, _| ctx } + end + + x.report 'ImmutableArrayContext.with_value' do + ImmutableArrayContext.with_value('key', 'value') { |ctx, _| ctx } + end + + x.report 'FiberLocalVarContext.with_value' do + FiberLocalVarContext.with_value('key', 'value') { |ctx, _| ctx } + end + + x.report 'FiberLocalLinkedListContext.with_value' do + FiberLocalLinkedListContext.with_value('key', 'value') { |ctx, _| ctx } + end + + x.report 'FiberLocalImmutableArrayContext.with_value' do + FiberLocalImmutableArrayContext.with_value('key', 'value') { |ctx, _| ctx } + end + + x.report 'FiberLocalArrayContext.with_value' do + FiberLocalArrayContext.with_value('key', 'value') { |ctx, _| ctx } + end + + x.compare! +end + +Benchmark.ipsa do |x| # rubocop:disable Metrics/BlockLength + x.report 'LinkedListContext.with_value recursive' do + LinkedListContext.with_value('key', 'value') do + LinkedListContext.with_value('key', 'value') do + LinkedListContext.with_value('key', 'value') do + LinkedListContext.with_value('key', 'value') do + LinkedListContext.with_value('key', 'value') do + LinkedListContext.with_value('key', 'value') do + LinkedListContext.with_value('key', 'value') do + LinkedListContext.with_value('key', 'value') do + LinkedListContext.with_value('key', 'value') do + LinkedListContext.with_value('key', 'value') { |ctx, _| ctx } + end + end + end + end + end + end + end + end + end + end + + x.report 'ArrayContext.with_value recursive' do + ArrayContext.with_value('key', 'value') do + ArrayContext.with_value('key', 'value') do + ArrayContext.with_value('key', 'value') do + ArrayContext.with_value('key', 'value') do + ArrayContext.with_value('key', 'value') do + ArrayContext.with_value('key', 'value') do + ArrayContext.with_value('key', 'value') do + ArrayContext.with_value('key', 'value') do + ArrayContext.with_value('key', 'value') do + ArrayContext.with_value('key', 'value') { |ctx, _| ctx } + end + end + end + end + end + end + end + end + end + end + + x.report 'ImmutableArrayContext.with_value recursive' do + ImmutableArrayContext.with_value('key', 'value') do + ImmutableArrayContext.with_value('key', 'value') do + ImmutableArrayContext.with_value('key', 'value') do + ImmutableArrayContext.with_value('key', 'value') do + ImmutableArrayContext.with_value('key', 'value') do + ImmutableArrayContext.with_value('key', 'value') do + ImmutableArrayContext.with_value('key', 'value') do + ImmutableArrayContext.with_value('key', 'value') do + ImmutableArrayContext.with_value('key', 'value') do + ImmutableArrayContext.with_value('key', 'value') { |ctx, _| ctx } + end + end + end + end + end + end + end + end + end + end + + x.report 'FiberAttributeContext.with_value recursive' do + FiberAttributeContext.with_value('key', 'value') do + FiberAttributeContext.with_value('key', 'value') do + FiberAttributeContext.with_value('key', 'value') do + FiberAttributeContext.with_value('key', 'value') do + FiberAttributeContext.with_value('key', 'value') do + FiberAttributeContext.with_value('key', 'value') do + FiberAttributeContext.with_value('key', 'value') do + FiberAttributeContext.with_value('key', 'value') do + FiberAttributeContext.with_value('key', 'value') do + FiberAttributeContext.with_value('key', 'value') { |ctx, _| ctx } + end + end + end + end + end + end + end + end + end + end + + x.report 'FiberLocalVarContext.with_value recursive' do + FiberLocalVarContext.with_value('key', 'value') do + FiberLocalVarContext.with_value('key', 'value') do + FiberLocalVarContext.with_value('key', 'value') do + FiberLocalVarContext.with_value('key', 'value') do + FiberLocalVarContext.with_value('key', 'value') do + FiberLocalVarContext.with_value('key', 'value') do + FiberLocalVarContext.with_value('key', 'value') do + FiberLocalVarContext.with_value('key', 'value') do + FiberLocalVarContext.with_value('key', 'value') do + FiberLocalVarContext.with_value('key', 'value') { |ctx, _| ctx } + end + end + end + end + end + end + end + end + end + end + + x.report 'FiberLocalLinkedListContext.with_value recursive' do + FiberLocalLinkedListContext.with_value('key', 'value') do + FiberLocalLinkedListContext.with_value('key', 'value') do + FiberLocalLinkedListContext.with_value('key', 'value') do + FiberLocalLinkedListContext.with_value('key', 'value') do + FiberLocalLinkedListContext.with_value('key', 'value') do + FiberLocalLinkedListContext.with_value('key', 'value') do + FiberLocalLinkedListContext.with_value('key', 'value') do + FiberLocalLinkedListContext.with_value('key', 'value') do + FiberLocalLinkedListContext.with_value('key', 'value') do + FiberLocalLinkedListContext.with_value('key', 'value') { |ctx, _| ctx } + end + end + end + end + end + end + end + end + end + end + + x.report 'FiberLocalImmutableArrayContext.with_value recursive' do + FiberLocalImmutableArrayContext.with_value('key', 'value') do + FiberLocalImmutableArrayContext.with_value('key', 'value') do + FiberLocalImmutableArrayContext.with_value('key', 'value') do + FiberLocalImmutableArrayContext.with_value('key', 'value') do + FiberLocalImmutableArrayContext.with_value('key', 'value') do + FiberLocalImmutableArrayContext.with_value('key', 'value') do + FiberLocalImmutableArrayContext.with_value('key', 'value') do + FiberLocalImmutableArrayContext.with_value('key', 'value') do + FiberLocalImmutableArrayContext.with_value('key', 'value') do + FiberLocalImmutableArrayContext.with_value('key', 'value') { |ctx, _| ctx } + end + end + end + end + end + end + end + end + end + end + + x.report 'FiberLocalArrayContext.with_value recursive' do + FiberLocalArrayContext.with_value('key', 'value') do + FiberLocalArrayContext.with_value('key', 'value') do + FiberLocalArrayContext.with_value('key', 'value') do + FiberLocalArrayContext.with_value('key', 'value') do + FiberLocalArrayContext.with_value('key', 'value') do + FiberLocalArrayContext.with_value('key', 'value') do + FiberLocalArrayContext.with_value('key', 'value') do + FiberLocalArrayContext.with_value('key', 'value') do + FiberLocalArrayContext.with_value('key', 'value') do + FiberLocalArrayContext.with_value('key', 'value') { |ctx, _| ctx } + end + end + end + end + end + end + end + end + end + end + + x.compare! +end diff --git a/api/lib/opentelemetry/context.rb b/api/lib/opentelemetry/context.rb index 05e96ed6bd..de4e2565b8 100644 --- a/api/lib/opentelemetry/context.rb +++ b/api/lib/opentelemetry/context.rb @@ -7,12 +7,13 @@ require 'opentelemetry/context/key' require 'opentelemetry/context/propagation' -module OpenTelemetry +module OpenTelemetry # rubocop:disable Style/Documentation + Fiber.attr_accessor :opentelemetry_context + # Manages context on a per-fiber basis class Context EMPTY_ENTRIES = {}.freeze - STACK_KEY = :__opentelemetry_context_storage__ - private_constant :EMPTY_ENTRIES, :STACK_KEY + private_constant :EMPTY_ENTRIES DetachError = Class.new(OpenTelemetry::Error) @@ -113,11 +114,9 @@ def value(key) current.value(key) end - # Clears the fiber-local Context stack. This allocates a new array for the - # stack, which is important in some use-cases to avoid sharing the backing - # array between fibers. + # Clears the fiber-local Context stack. def clear - Thread.current[STACK_KEY] = [] + Fiber.current.opentelemetry_context = [] end def empty @@ -127,7 +126,7 @@ def empty private def stack - Thread.current[STACK_KEY] ||= [] + Fiber.current.opentelemetry_context ||= [] end end diff --git a/api/opentelemetry-api.gemspec b/api/opentelemetry-api.gemspec index 50681eb1f4..dab247f30d 100644 --- a/api/opentelemetry-api.gemspec +++ b/api/opentelemetry-api.gemspec @@ -27,6 +27,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'benchmark-ipsa', '~> 0.2.0' spec.add_development_dependency 'bundler', '>= 1.17' + spec.add_development_dependency 'concurrent-ruby', '~> 1.3' spec.add_development_dependency 'faraday', '~> 0.13' spec.add_development_dependency 'minitest', '~> 5.0' spec.add_development_dependency 'opentelemetry-test-helpers' diff --git a/api/test/opentelemetry/context_test.rb b/api/test/opentelemetry/context_test.rb index eb91a907b1..a3f017adb7 100644 --- a/api/test/opentelemetry/context_test.rb +++ b/api/test/opentelemetry/context_test.rb @@ -289,7 +289,74 @@ end end + describe 'fibers' do + it 'is isolated with respect to Fiber-local variable manipulation' do + OpenTelemetry::TestHelpers.with_test_logger do |log_stream| + ctx = new_context + Context.with_current(ctx) do + # This is based on code in ActionController::Live#process: + # https://github.com/rails/rails/blob/ad0105c13f61d145a659004efb928a643104e973/actionpack/lib/action_controller/metal/live.rb#L270 + t1 = Thread.current + locals = t1.keys.map { |key| [key, t1[key]] } + Fiber.new do + t2 = Thread.current + locals.each { |k, v| t2[k] = v } + # Manipulate _this fiber's_ context stack. + Context.attach(new_context) + ensure + locals.each { |k, _| t2[k] = nil } # rubocop:disable Style/HashEachMethods (locals is not a Hash) + end.resume + end + _(log_stream.string).must_be_empty + end + end + + it 'is isolated with respect to Fiber-local storage manipulation' do + skip unless Fiber.current.respond_to?(:storage) + OpenTelemetry::TestHelpers.with_test_logger do |log_stream| + ctx = new_context + Context.with_current(ctx) do + # This is based on code in ActionController::Live#process, modified to use Fiber-local storage: + # https://github.com/rails/rails/blob/ad0105c13f61d145a659004efb928a643104e973/actionpack/lib/action_controller/metal/live.rb#L270 + f1 = Fiber.current + Fiber[:foo] = :bar + locals = f1.storage + Fiber.new do + f2 = Fiber.current + locals.each { |k, v| f2.storage[k] = v } + # Manipulate _this fiber's_ context stack. + Context.attach(new_context) + ensure + locals.each_key { |k| f2.storage.delete(k) } + end.resume + end + _(log_stream.string).must_be_empty + end + end + end + describe 'threading' do + it 'is isolated with respect to Fiber-local variable manipulation' do + OpenTelemetry::TestHelpers.with_test_logger do |log_stream| + ctx = new_context + Context.with_current(ctx) do + # This is based on code in ActionController::Live#process: + # https://github.com/rails/rails/blob/ad0105c13f61d145a659004efb928a643104e973/actionpack/lib/action_controller/metal/live.rb#L270 + t1 = Thread.current + locals = t1.keys.map { |key| [key, t1[key]] } + Thread.new do + t2 = Thread.current + locals.each { |k, v| t2[k] = v } + # Manipulate _this thread's_ context stack. + Context.attach(new_context) + ensure + locals.each { |k, _| t2[k] = nil } # rubocop:disable Style/HashEachMethods (locals is not a Hash) + end.join + end + _(log_stream.string).must_be_empty + end + end + it 'unwinds the stack on each thread' do ctx = new_context t1_ctx_before = Context.current