@@ -54,11 +54,88 @@ class Project
54
54
DEFAULT_CMAB_CACHE_TIMEOUT = ( 30 * 60 * 1000 )
55
55
DEFAULT_CMAB_CACHE_SIZE = 1000
56
56
57
+ # Class-level instance cache to prevent memory leaks from repeated initialization
58
+ @@instance_cache = { }
59
+ @@cache_mutex = Mutex . new
60
+
57
61
attr_reader :notification_center
58
62
# @api no-doc
59
63
attr_reader :config_manager , :decision_service , :error_handler , :event_dispatcher ,
60
64
:event_processor , :logger , :odp_manager , :stopped
61
65
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
+
62
139
# Constructor for Projects.
63
140
#
64
141
# @param datafile - JSON string representing the project.
@@ -163,6 +240,23 @@ def initialize(
163
240
flush_interval : event_processor_options [ :flush_interval ] || BatchEventProcessor ::DEFAULT_BATCH_INTERVAL
164
241
)
165
242
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
166
260
end
167
261
168
262
# Create a context of the user for which decision APIs will be called.
@@ -936,6 +1030,14 @@ def close
936
1030
@config_manager . stop! if @config_manager . respond_to? ( :stop! )
937
1031
@event_processor . stop! if @event_processor . respond_to? ( :stop! )
938
1032
@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 }
939
1041
end
940
1042
941
1043
def get_optimizely_config
0 commit comments