@@ -54,11 +54,88 @@ class Project
5454 DEFAULT_CMAB_CACHE_TIMEOUT = ( 30 * 60 * 1000 )
5555 DEFAULT_CMAB_CACHE_SIZE = 1000
5656
57+ # Class-level instance cache to prevent memory leaks from repeated initialization
58+ @@instance_cache = { }
59+ @@cache_mutex = Mutex . new
60+
5761 attr_reader :notification_center
5862 # @api no-doc
5963 attr_reader :config_manager , :decision_service , :error_handler , :event_dispatcher ,
6064 :event_processor , :logger , :odp_manager , :stopped
6165
66+ # Get or create a cached Project instance for static datafile configurations
67+ # This prevents memory leaks when the same datafile is used repeatedly
68+ #
69+ # @param datafile - JSON string representing the project
70+ # @param options - Hash of initialization options (optional)
71+ # @return [Project] Cached or new Project instance
72+ def self . get_or_create_instance ( datafile : nil , **options )
73+ # Only cache static datafile configurations (no sdk_key, no custom managers)
74+ return new ( datafile : datafile , **options ) if should_skip_cache? ( datafile , options )
75+
76+ cache_key = generate_cache_key ( datafile , options )
77+
78+ @@cache_mutex . synchronize do
79+ # Return existing instance if available and not stopped
80+ if @@instance_cache [ cache_key ] && !@@instance_cache [ cache_key ] . stopped
81+ return @@instance_cache [ cache_key ]
82+ end
83+
84+ # Create new instance and cache it
85+ instance = new ( datafile : datafile , **options )
86+ @@instance_cache [ cache_key ] = instance
87+ instance
88+ end
89+ end
90+
91+ # Clear all cached instances and properly close them
92+ def self . clear_instance_cache!
93+ @@cache_mutex . synchronize do
94+ # First stop all instances without removing them from cache to avoid deadlock
95+ @@instance_cache . each_value do |instance |
96+ next if instance . stopped
97+
98+ instance . instance_variable_set ( :@stopped , true )
99+ instance . config_manager . stop! if instance . config_manager . respond_to? ( :stop! )
100+ instance . event_processor . stop! if instance . event_processor . respond_to? ( :stop! )
101+ instance . odp_manager . stop!
102+ end
103+ # Then clear the cache
104+ @@instance_cache . clear
105+ end
106+ end
107+
108+ # Get count of cached instances (for testing/monitoring)
109+ def self . cached_instance_count
110+ @@cache_mutex . synchronize { @@instance_cache . size }
111+ end
112+
113+ private_class_method def self . should_skip_cache? ( datafile , options )
114+ # Don't cache if using dynamic features that would make sharing unsafe
115+ return true if options [ :sdk_key ] ||
116+ options [ :config_manager ] ||
117+ options [ :event_processor ] ||
118+ options [ :user_profile_service ] ||
119+ datafile . nil? || datafile . empty?
120+
121+ # Also don't cache if custom loggers or error handlers that might have state
122+ return true if options [ :logger ] || options [ :error_handler ] || options [ :event_dispatcher ]
123+
124+ false
125+ end
126+
127+ private_class_method def self . generate_cache_key ( datafile , options )
128+ # Create cache key from datafile content and relevant options
129+ require 'digest'
130+ content_hash = Digest ::SHA256 . hexdigest ( datafile )
131+ options_hash = {
132+ skip_json_validation : options [ :skip_json_validation ] ,
133+ default_decide_options : options [ :default_decide_options ] &.sort ,
134+ event_processor_options : options [ :event_processor_options ]
135+ }
136+ "#{ content_hash } _#{ Digest ::SHA256 . hexdigest ( options_hash . to_s ) } "
137+ end
138+
62139 # Constructor for Projects.
63140 #
64141 # @param datafile - JSON string representing the project.
@@ -163,6 +240,23 @@ def initialize(
163240 flush_interval : event_processor_options [ :flush_interval ] || BatchEventProcessor ::DEFAULT_BATCH_INTERVAL
164241 )
165242 end
243+
244+ # Set up finalizer to ensure cleanup if close() is not called explicitly
245+ ObjectSpace . define_finalizer ( self , self . class . create_finalizer ( @config_manager , @event_processor , @odp_manager ) )
246+ end
247+
248+ # Create finalizer proc to clean up background threads
249+ # This ensures cleanup even if close() is not explicitly called
250+ def self . create_finalizer ( config_manager , event_processor , odp_manager )
251+ proc do
252+ begin
253+ config_manager . stop! if config_manager . respond_to? ( :stop! )
254+ event_processor . stop! if event_processor . respond_to? ( :stop! )
255+ odp_manager . stop! if odp_manager . respond_to? ( :stop! )
256+ rescue
257+ # Suppress errors during finalization to avoid issues during GC
258+ end
259+ end
166260 end
167261
168262 # Create a context of the user for which decision APIs will be called.
@@ -936,6 +1030,14 @@ def close
9361030 @config_manager . stop! if @config_manager . respond_to? ( :stop! )
9371031 @event_processor . stop! if @event_processor . respond_to? ( :stop! )
9381032 @odp_manager . stop!
1033+
1034+ # Remove this instance from the cache if it exists
1035+ # Note: we don't synchronize here to avoid deadlock when called from clear_instance_cache!
1036+ self . class . send ( :remove_from_cache_unsafe , self )
1037+ end
1038+
1039+ private_class_method def self . remove_from_cache_unsafe ( instance )
1040+ @@instance_cache . delete_if { |_key , cached_instance | cached_instance == instance }
9391041 end
9401042
9411043 def get_optimizely_config
0 commit comments