forest_admin_datasource_rpc 1.16.10 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c76facc94fe343ca29176a93b36f2a85b1d9e90f33c9d2e9a8300e0c1ff824c8
4
- data.tar.gz: f47529d4767faf8ec9aaf7fe3c3863e26c092a526fc1c99fc6596fd8931ff235
3
+ metadata.gz: 9485d38ceffa876811869166eb3b74209f0c1a9807c4a2b3571b1271a552d428
4
+ data.tar.gz: 4dff025fcea11c17531f412b37ae13dfa68485484aa74fd7ccfcf5c3b11fc829
5
5
  SHA512:
6
- metadata.gz: dd67fcda71f0307b651d1bb00c98ac03ac184ec9717f623394f3865680497220bc9558b10501aa9052ecf7ca7d94c0fac5f0486e5f1fc5c6f5e61ce0848a4351
7
- data.tar.gz: 2a6d7cd793499fffb01ab99c75f5fbec550aef76b71aeeb82dd582cbd1f0274ef4f40ccb1080c364c58687d9451eea7170d453e8fa7c570bf32c0ff0a2cbf6ab
6
+ metadata.gz: d0212c1ab3221ddc96ab35dbe789e362108cdb2d48cad36c47b47920545f9d55df998dc55bf94f8f6779f2b8174ca2fcb0e40817d39e7c24f8337ecfc5c9f092
7
+ data.tar.gz: 20246dff2afeae14a3d2e8b7aed1dc64f5179b1254fb3bfe8f9ea3c5e585b312d82a85b04ccd6ba78ae4486a650be63fb53f12f65c6a83bdf9343f46682f8c3d
@@ -36,7 +36,6 @@ admin work on any Ruby application."
36
36
  spec.add_dependency "bigdecimal"
37
37
  spec.add_dependency "csv"
38
38
  spec.add_dependency "faraday", "~> 2.7"
39
- spec.add_dependency "ld-eventsource", "~> 2.2"
40
39
  spec.add_dependency "mutex_m"
41
40
  spec.add_dependency "ostruct"
42
41
  spec.add_dependency "zeitwerk", "~> 2.3"
@@ -5,20 +5,17 @@ require 'time'
5
5
 
6
6
  module ForestAdminDatasourceRpc
7
7
  module Utils
8
+ # Response wrapper for schema requests that need ETag
9
+ class SchemaResponse
10
+ attr_reader :body, :etag
11
+
12
+ def initialize(body, etag = nil)
13
+ @body = body
14
+ @etag = etag
15
+ end
16
+ end
17
+
8
18
  class RpcClient
9
- # RpcClient handles HTTP communication with the RPC Agent.
10
- #
11
- # Error Handling:
12
- # When the RPC agent returns an error, this client automatically maps HTTP status codes
13
- # to appropriate Forest Admin exception types. This ensures business errors from the
14
- # RPC agent are properly propagated to the datasource_rpc.
15
- #
16
- # To add support for a new error type:
17
- # 1. Add the status code and exception class to ERROR_STATUS_MAP
18
- # 2. (Optional) Add a default message to generate_default_message method
19
- # 3. Tests will automatically cover the new mapping
20
-
21
- # Map HTTP status codes to Forest Admin exception classes
22
19
  ERROR_STATUS_MAP = {
23
20
  400 => ForestAdminAgent::Http::Exceptions::ValidationError,
24
21
  401 => ForestAdminAgent::Http::Exceptions::AuthenticationOpenIdClient,
@@ -28,12 +25,39 @@ module ForestAdminDatasourceRpc
28
25
  422 => ForestAdminAgent::Http::Exceptions::UnprocessableError
29
26
  }.freeze
30
27
 
28
+ DEFAULT_ERROR_MESSAGES = {
29
+ 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden',
30
+ 404 => 'Not Found', 409 => 'Conflict', 422 => 'Unprocessable Entity'
31
+ }.freeze
32
+
33
+ HTTP_NOT_MODIFIED = 304
34
+ NotModified = Class.new
35
+
31
36
  def initialize(api_url, auth_secret)
32
37
  @api_url = api_url
33
38
  @auth_secret = auth_secret
34
39
  end
35
40
 
36
- def call_rpc(endpoint, caller: nil, method: :get, payload: nil, symbolize_keys: false)
41
+ # rubocop:disable Metrics/ParameterLists
42
+ def call_rpc(endpoint, caller: nil, method: :get, payload: nil, symbolize_keys: false, if_none_match: nil)
43
+ response = make_request(endpoint, caller: caller, method: method, payload: payload,
44
+ symbolize_keys: symbolize_keys, if_none_match: if_none_match)
45
+ handle_response(response)
46
+ end
47
+
48
+ # rubocop:enable Metrics/ParameterLists
49
+
50
+ def fetch_schema(endpoint, if_none_match: nil)
51
+ response = make_request(endpoint, method: :get, symbolize_keys: true, if_none_match: if_none_match)
52
+ handle_response_with_etag(response)
53
+ end
54
+
55
+ private
56
+
57
+ # rubocop:disable Metrics/ParameterLists
58
+ def make_request(endpoint, caller: nil, method: :get, payload: nil, symbolize_keys: false, if_none_match: nil)
59
+ log_request_start(method, endpoint, if_none_match)
60
+
37
61
  client = Faraday.new(url: @api_url) do |faraday|
38
62
  faraday.request :json
39
63
  faraday.response :json, parser_options: { symbolize_names: symbolize_keys }
@@ -51,22 +75,42 @@ module ForestAdminDatasourceRpc
51
75
  }
52
76
 
53
77
  headers['forest_caller'] = caller.to_json if caller
78
+ headers['If-None-Match'] = %("#{if_none_match}") if if_none_match
54
79
 
55
80
  response = client.send(method, endpoint, payload, headers)
56
-
57
- handle_response(response)
81
+ log_request_complete(response, endpoint)
82
+ response
58
83
  end
59
-
60
- private
84
+ # rubocop:enable Metrics/ParameterLists
61
85
 
62
86
  def generate_signature(timestamp)
63
87
  OpenSSL::HMAC.hexdigest('SHA256', @auth_secret, timestamp)
64
88
  end
65
89
 
66
90
  def handle_response(response)
67
- raise_appropriate_error(response) unless response.success?
91
+ return response.body if response.success?
92
+ return NotModified if response.status == HTTP_NOT_MODIFIED
68
93
 
69
- response.body
94
+ raise_appropriate_error(response)
95
+ end
96
+
97
+ def handle_response_with_etag(response)
98
+ if response.success?
99
+ etag = extract_etag(response)
100
+ ForestAdminAgent::Facades::Container.logger&.log(
101
+ 'Debug',
102
+ "[RPC Client] Schema response received (status: #{response.status}, ETag: #{etag || "none"})"
103
+ )
104
+ return SchemaResponse.new(response.body, etag)
105
+ end
106
+ return NotModified if response.status == HTTP_NOT_MODIFIED
107
+
108
+ raise_appropriate_error(response)
109
+ end
110
+
111
+ def extract_etag(response)
112
+ etag = response.headers['ETag'] || response.headers['etag']
113
+ etag&.gsub(/\A"?|"?\z/, '')
70
114
  end
71
115
 
72
116
  def raise_appropriate_error(response)
@@ -75,6 +119,11 @@ module ForestAdminDatasourceRpc
75
119
  url = response.env.url
76
120
  message = error_body[:message] || generate_default_message(status, url)
77
121
 
122
+ ForestAdminAgent::Facades::Container.logger&.log(
123
+ 'Error',
124
+ "[RPC Client] Request failed (status: #{status}, URL: #{url}, message: #{message})"
125
+ )
126
+
78
127
  exception_class = ERROR_STATUS_MAP[status]
79
128
 
80
129
  if exception_class
@@ -89,37 +138,18 @@ module ForestAdminDatasourceRpc
89
138
  end
90
139
 
91
140
  def generate_default_message(status, url)
92
- default_messages = {
93
- 400 => "Bad Request: #{url}",
94
- 401 => "Unauthorized: #{url}",
95
- 403 => "Forbidden: #{url}",
96
- 404 => "Not Found: #{url}",
97
- 409 => "Conflict: #{url}",
98
- 422 => "Unprocessable Entity: #{url}"
99
- }
100
-
101
- default_messages[status] || "Unknown error (#{url})"
141
+ prefix = DEFAULT_ERROR_MESSAGES[status] || 'Unknown error'
142
+ "#{prefix}: #{url}"
102
143
  end
103
144
 
104
145
  def parse_error_body(response)
105
146
  body = response.body
106
-
107
- # If body is already a hash (Faraday parsed it as JSON)
108
147
  return symbolize_error_keys(body) if body.is_a?(Hash)
148
+ return { message: 'Unknown error' } unless body.is_a?(String) && !body.empty?
109
149
 
110
- # Try to parse as JSON if it's a string
111
- if body.is_a?(String) && !body.empty?
112
- begin
113
- parsed = JSON.parse(body)
114
- return symbolize_error_keys(parsed)
115
- rescue JSON::ParserError
116
- # If parsing fails, return the body as the message
117
- return { message: body }
118
- end
119
- end
120
-
121
- # Fallback for empty or unexpected body types
122
- { message: 'Unknown error' }
150
+ symbolize_error_keys(JSON.parse(body))
151
+ rescue JSON::ParserError
152
+ { message: body }
123
153
  end
124
154
 
125
155
  def symbolize_error_keys(hash)
@@ -129,6 +159,21 @@ module ForestAdminDatasourceRpc
129
159
  name: hash['name'] || hash[:name]
130
160
  }.compact
131
161
  end
162
+
163
+ def log_request_start(method, endpoint, if_none_match)
164
+ etag_info = if_none_match ? " (If-None-Match: #{if_none_match})" : ''
165
+ ForestAdminAgent::Facades::Container.logger&.log(
166
+ 'Debug',
167
+ "[RPC Client] Sending #{method.to_s.upcase} request to #{@api_url}#{endpoint}#{etag_info}"
168
+ )
169
+ end
170
+
171
+ def log_request_complete(response, endpoint)
172
+ ForestAdminAgent::Facades::Container.logger&.log(
173
+ 'Debug',
174
+ "[RPC Client] Response received (status: #{response.status}, endpoint: #{endpoint})"
175
+ )
176
+ end
132
177
  end
133
178
  end
134
179
  end
@@ -0,0 +1,219 @@
1
+ require 'openssl'
2
+ require 'json'
3
+ require 'time'
4
+
5
+ module ForestAdminDatasourceRpc
6
+ module Utils
7
+ class SchemaPollingClient
8
+ attr_reader :closed
9
+
10
+ DEFAULT_POLLING_INTERVAL = 600 # seconds (10 minutes)
11
+ MIN_POLLING_INTERVAL = 1 # seconds (minimum safe interval)
12
+ MAX_POLLING_INTERVAL = 3600 # seconds (1 hour max)
13
+
14
+ def initialize(uri, auth_secret, options = {}, &on_schema_change)
15
+ @uri = uri
16
+ @auth_secret = auth_secret
17
+ @polling_interval = options[:polling_interval] || DEFAULT_POLLING_INTERVAL
18
+ @on_schema_change = on_schema_change
19
+ @closed = false
20
+ @cached_etag = nil
21
+ @polling_thread = nil
22
+ @mutex = Mutex.new
23
+ @connection_attempts = 0
24
+
25
+ # Validate polling interval
26
+ validate_polling_interval!
27
+
28
+ # RPC client for schema fetching with ETag support
29
+ @rpc_client = RpcClient.new(@uri, @auth_secret)
30
+ end
31
+
32
+ def start
33
+ return if @closed
34
+
35
+ @mutex.synchronize do
36
+ return if @polling_thread&.alive?
37
+
38
+ @polling_thread = Thread.new do
39
+ polling_loop
40
+ rescue StandardError => e
41
+ ForestAdminAgent::Facades::Container.logger&.log(
42
+ 'Error',
43
+ "[Schema Polling] Unexpected error in polling loop: #{e.class} - #{e.message}"
44
+ )
45
+ end
46
+ end
47
+
48
+ ForestAdminAgent::Facades::Container.logger&.log(
49
+ 'Info',
50
+ "[Schema Polling] Polling started (interval: #{@polling_interval}s)"
51
+ )
52
+ end
53
+
54
+ def stop
55
+ return if @closed
56
+
57
+ @closed = true
58
+ ForestAdminAgent::Facades::Container.logger&.log('Debug', '[Schema Polling] Stopping polling')
59
+
60
+ @mutex.synchronize do
61
+ if @polling_thread&.alive?
62
+ @polling_thread.kill
63
+ @polling_thread = nil
64
+ end
65
+ end
66
+
67
+ ForestAdminAgent::Facades::Container.logger&.log('Debug', '[Schema Polling] Polling stopped')
68
+ end
69
+
70
+ private
71
+
72
+ def polling_loop
73
+ ForestAdminAgent::Facades::Container.logger&.log(
74
+ 'Debug',
75
+ "[Schema Polling] Starting polling loop (interval: #{@polling_interval}s)"
76
+ )
77
+
78
+ loop do
79
+ break if @closed
80
+
81
+ begin
82
+ check_schema
83
+ rescue StandardError => e
84
+ handle_error(e)
85
+ end
86
+
87
+ # Sleep with interrupt check (check every second for early termination)
88
+ ForestAdminAgent::Facades::Container.logger&.log(
89
+ 'Debug',
90
+ "[Schema Polling] Waiting #{@polling_interval}s before next check (current ETag: #{@cached_etag || "none"})"
91
+ )
92
+ remaining = @polling_interval
93
+ while remaining.positive? && !@closed
94
+ sleep([remaining, 1].min)
95
+ remaining -= 1
96
+ end
97
+ end
98
+ end
99
+
100
+ def check_schema
101
+ @connection_attempts += 1
102
+ log_checking_schema
103
+
104
+ result = @rpc_client.fetch_schema('/forest/rpc-schema', if_none_match: @cached_etag)
105
+ handle_schema_result(result)
106
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
107
+ log_connection_error(e)
108
+ rescue ForestAdminAgent::Http::Exceptions::AuthenticationOpenIdClient => e
109
+ log_authentication_error(e)
110
+ rescue StandardError => e
111
+ log_unexpected_error(e)
112
+ end
113
+
114
+ def handle_error(error)
115
+ ForestAdminAgent::Facades::Container.logger&.log(
116
+ 'Error',
117
+ "[Schema Polling] Error during schema check: #{error.class} - #{error.message}"
118
+ )
119
+ end
120
+
121
+ def trigger_schema_change_callback(schema)
122
+ return unless @on_schema_change
123
+
124
+ ForestAdminAgent::Facades::Container.logger&.log(
125
+ 'Debug',
126
+ '[Schema Polling] Invoking schema change callback'
127
+ )
128
+ begin
129
+ @on_schema_change.call(schema)
130
+ ForestAdminAgent::Facades::Container.logger&.log(
131
+ 'Debug',
132
+ '[Schema Polling] Schema change callback completed successfully'
133
+ )
134
+ rescue StandardError => e
135
+ error_msg = "[Schema Polling] Error in schema change callback: #{e.class} - #{e.message}"
136
+ backtrace = "\n#{e.backtrace&.first(5)&.join("\n")}"
137
+ ForestAdminAgent::Facades::Container.logger&.log('Error', error_msg + backtrace)
138
+ end
139
+ end
140
+
141
+ def log_checking_schema
142
+ etag_info = @cached_etag ? "with ETag: #{@cached_etag}" : 'without ETag (initial fetch)'
143
+ msg = "[Schema Polling] Checking schema from #{@uri}/forest/rpc-schema " \
144
+ "(attempt ##{@connection_attempts}, #{etag_info})"
145
+ ForestAdminAgent::Facades::Container.logger&.log('Debug', msg)
146
+ end
147
+
148
+ def handle_schema_result(result)
149
+ if result == RpcClient::NotModified
150
+ handle_schema_unchanged
151
+ else
152
+ handle_schema_changed(result)
153
+ end
154
+ end
155
+
156
+ def handle_schema_unchanged
157
+ ForestAdminAgent::Facades::Container.logger&.log(
158
+ 'Debug',
159
+ "[Schema Polling] Schema unchanged (HTTP 304 Not Modified), ETag still valid: #{@cached_etag}"
160
+ )
161
+ @connection_attempts = 0
162
+ end
163
+
164
+ def handle_schema_changed(result)
165
+ new_etag = result.etag
166
+ @cached_etag.nil? ? handle_initial_schema(new_etag) : handle_schema_update(result.body, new_etag)
167
+ @connection_attempts = 0
168
+ end
169
+
170
+ def handle_initial_schema(etag)
171
+ @cached_etag = etag
172
+ ForestAdminAgent::Facades::Container.logger&.log(
173
+ 'Debug',
174
+ "[Schema Polling] Initial schema loaded successfully (ETag: #{etag})"
175
+ )
176
+ end
177
+
178
+ def handle_schema_update(schema, etag)
179
+ old_etag = @cached_etag
180
+ @cached_etag = etag
181
+ msg = "[Schema Polling] Schema changed detected (old ETag: #{old_etag}, new ETag: #{etag}), " \
182
+ 'triggering reload callback'
183
+ ForestAdminAgent::Facades::Container.logger&.log('Info', msg)
184
+ trigger_schema_change_callback(schema)
185
+ end
186
+
187
+ def log_connection_error(error)
188
+ ForestAdminAgent::Facades::Container.logger&.log(
189
+ 'Warn',
190
+ "[Schema Polling] Connection error: #{error.class} - #{error.message}"
191
+ )
192
+ end
193
+
194
+ def log_authentication_error(error)
195
+ ForestAdminAgent::Facades::Container.logger&.log(
196
+ 'Error',
197
+ "[Schema Polling] Authentication error: #{error.message}"
198
+ )
199
+ end
200
+
201
+ def log_unexpected_error(error)
202
+ ForestAdminAgent::Facades::Container.logger&.log(
203
+ 'Error',
204
+ "[Schema Polling] Unexpected error: #{error.class} - #{error.message}"
205
+ )
206
+ end
207
+
208
+ def validate_polling_interval!
209
+ if @polling_interval < MIN_POLLING_INTERVAL
210
+ raise ArgumentError,
211
+ "Schema polling interval too short: #{@polling_interval}s (minimum: #{MIN_POLLING_INTERVAL}s)"
212
+ elsif @polling_interval > MAX_POLLING_INTERVAL
213
+ raise ArgumentError,
214
+ "Schema polling interval too long: #{@polling_interval}s (maximum: #{MAX_POLLING_INTERVAL}s)"
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -2,7 +2,7 @@ module ForestAdminDatasourceRpc
2
2
  class Datasource < ForestAdminDatasourceToolkit::Datasource
3
3
  include ForestAdminDatasourceRpc::Utils
4
4
 
5
- def initialize(options, introspection, sse_client = nil)
5
+ def initialize(options, introspection, schema_polling_client = nil)
6
6
  super()
7
7
 
8
8
  ForestAdminAgent::Facades::Container.logger.log(
@@ -18,7 +18,7 @@ module ForestAdminDatasourceRpc
18
18
  @options = options
19
19
  @charts = introspection[:charts]
20
20
  @rpc_relations = introspection[:rpc_relations]
21
- @sse_client = sse_client
21
+ @schema_polling_client = schema_polling_client
22
22
  @cleaned_up = false
23
23
 
24
24
  native_query_connections = introspection[:native_query_connections] || []
@@ -58,10 +58,10 @@ module ForestAdminDatasourceRpc
58
58
 
59
59
  @cleaned_up = true
60
60
 
61
- if @sse_client
62
- ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource] Closing SSE connection...')
63
- @sse_client.close
64
- ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource] SSE connection closed')
61
+ if @schema_polling_client
62
+ ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource] Stopping schema polling...')
63
+ @schema_polling_client.stop
64
+ ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource] Schema polling stopped')
65
65
  end
66
66
  rescue StandardError => e
67
67
  ForestAdminAgent::Facades::Container.logger&.log(
@@ -1,3 +1,3 @@
1
1
  module ForestAdminDatasourceRpc
2
- VERSION = "1.16.10"
2
+ VERSION = "1.18.0"
3
3
  end
@@ -8,15 +8,30 @@ loader.setup
8
8
  module ForestAdminDatasourceRpc
9
9
  class Error < StandardError; end
10
10
 
11
+ # Build a RPC datasource with schema polling enabled.
12
+ #
13
+ # @param options [Hash] Configuration options
14
+ # @option options [String] :uri The URI of the RPC agent
15
+ # @option options [String] :auth_secret The authentication secret (optional, will use cache if not provided)
16
+ # @option options [Integer] :schema_polling_interval Polling interval in seconds (optional)
17
+ # - Default: 600 seconds (10 minutes)
18
+ # - Can be overridden with ENV['SCHEMA_POLLING_INTERVAL']
19
+ # - Valid range: 1-3600 seconds
20
+ # - Priority: options[:schema_polling_interval] > ENV['SCHEMA_POLLING_INTERVAL'] > default
21
+ # - Example: SCHEMA_POLLING_INTERVAL=30 for development (30 seconds)
22
+ #
23
+ # @return [ForestAdminDatasourceRpc::Datasource] The configured datasource with schema polling
11
24
  def self.build(options)
12
25
  uri = options[:uri]
13
26
  auth_secret = options[:auth_secret] || ForestAdminAgent::Facades::Container.cache(:auth_secret)
14
27
  ForestAdminAgent::Facades::Container.logger.log('Info', "Getting schema from RPC agent on #{uri}.")
15
28
 
29
+ schema = nil
30
+
16
31
  begin
17
32
  rpc_client = Utils::RpcClient.new(uri, auth_secret)
18
- schema = rpc_client.call_rpc('/forest/rpc-schema', method: :get, symbolize_keys: true)
19
- last_hash_schema = Digest::SHA1.hexdigest(schema.to_h.to_s)
33
+ response = rpc_client.fetch_schema('/forest/rpc-schema')
34
+ schema = response.body
20
35
  rescue Faraday::ConnectionFailed => e
21
36
  ForestAdminAgent::Facades::Container.logger.log(
22
37
  'Error',
@@ -43,21 +58,31 @@ module ForestAdminDatasourceRpc
43
58
  # return empty datasource for not breaking stack
44
59
  ForestAdminDatasourceToolkit::Datasource.new
45
60
  else
46
- sse = Utils::SseClient.new("#{uri}/forest/sse", auth_secret) do
47
- ForestAdminAgent::Facades::Container.logger.log('Info', 'RPC server stopped, checking schema...')
48
- new_schema = rpc_client.call_rpc('/forest/rpc-schema', method: :get, symbolize_keys: true)
61
+ # Create schema polling client with configurable polling interval
62
+ # Priority: options[:schema_polling_interval] > ENV['SCHEMA_POLLING_INTERVAL'] > default (600)
63
+ polling_interval = if options[:schema_polling_interval]
64
+ options[:schema_polling_interval]
65
+ elsif ENV['SCHEMA_POLLING_INTERVAL']
66
+ ENV['SCHEMA_POLLING_INTERVAL'].to_i
67
+ else
68
+ 600 # 10 minutes by default
69
+ end
70
+
71
+ polling_options = {
72
+ polling_interval: polling_interval
73
+ }
49
74
 
50
- if last_hash_schema == Digest::SHA1.hexdigest(new_schema.to_h.to_s)
51
- ForestAdminAgent::Facades::Container.logger.log('Debug', '[RPCDatasource] Schema has not changed')
52
- else
53
- ForestAdminAgent::Builder::AgentFactory.instance.reload!
54
- end
75
+ schema_polling = Utils::SchemaPollingClient.new(uri, auth_secret, polling_options) do
76
+ # Callback when schema change is detected
77
+ logger = ForestAdminAgent::Facades::Container.logger
78
+ logger.log('Info', '[RPCDatasource] Schema change detected, reloading agent...')
79
+ ForestAdminAgent::Builder::AgentFactory.instance.reload!
55
80
  end
56
- sse.start
81
+ schema_polling.start
57
82
 
58
- datasource = ForestAdminDatasourceRpc::Datasource.new(options, schema, sse)
83
+ datasource = ForestAdminDatasourceRpc::Datasource.new(options, schema, schema_polling)
59
84
 
60
- # Setup cleanup hooks for proper SSE client shutdown
85
+ # Setup cleanup hooks for proper schema polling client shutdown
61
86
  setup_cleanup_hooks(datasource)
62
87
 
63
88
  datasource
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_datasource_rpc
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.16.10
4
+ version: 1.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2025-12-09 00:00:00.000000000 Z
12
+ date: 2025-12-11 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -67,20 +67,6 @@ dependencies:
67
67
  - - "~>"
68
68
  - !ruby/object:Gem::Version
69
69
  version: '2.7'
70
- - !ruby/object:Gem::Dependency
71
- name: ld-eventsource
72
- requirement: !ruby/object:Gem::Requirement
73
- requirements:
74
- - - "~>"
75
- - !ruby/object:Gem::Version
76
- version: '2.2'
77
- type: :runtime
78
- prerelease: false
79
- version_requirements: !ruby/object:Gem::Requirement
80
- requirements:
81
- - - "~>"
82
- - !ruby/object:Gem::Version
83
- version: '2.2'
84
70
  - !ruby/object:Gem::Dependency
85
71
  name: mutex_m
86
72
  requirement: !ruby/object:Gem::Requirement
@@ -139,7 +125,7 @@ files:
139
125
  - forest_admin_datasource_rpc.gemspec
140
126
  - lib/forest_admin_datasource_rpc.rb
141
127
  - lib/forest_admin_datasource_rpc/Utils/rpc_client.rb
142
- - lib/forest_admin_datasource_rpc/Utils/sse_client.rb
128
+ - lib/forest_admin_datasource_rpc/Utils/schema_polling_client.rb
143
129
  - lib/forest_admin_datasource_rpc/collection.rb
144
130
  - lib/forest_admin_datasource_rpc/datasource.rb
145
131
  - lib/forest_admin_datasource_rpc/version.rb
@@ -1,212 +0,0 @@
1
- require 'openssl'
2
- require 'json'
3
- require 'time'
4
- require 'ld-eventsource'
5
-
6
- module ForestAdminDatasourceRpc
7
- module Utils
8
- class SseClient
9
- attr_reader :closed
10
-
11
- MAX_BACKOFF_DELAY = 30 # seconds
12
- INITIAL_BACKOFF_DELAY = 2 # seconds
13
-
14
- def initialize(uri, auth_secret, &on_rpc_stop)
15
- @uri = uri
16
- @auth_secret = auth_secret
17
- @on_rpc_stop = on_rpc_stop
18
- @client = nil
19
- @closed = false
20
- @connection_attempts = 0
21
- @reconnect_thread = nil
22
- @connecting = false
23
- end
24
-
25
- def start
26
- return if @closed
27
-
28
- attempt_connection
29
- end
30
-
31
- def close
32
- return if @closed
33
-
34
- @closed = true
35
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] Closing connection')
36
-
37
- # Stop reconnection thread if running
38
- if @reconnect_thread&.alive?
39
- @reconnect_thread.kill
40
- @reconnect_thread = nil
41
- end
42
-
43
- begin
44
- @client&.close
45
- rescue StandardError => e
46
- ForestAdminAgent::Facades::Container.logger&.log('Debug',
47
- "[SSE Client] Error during close: #{e.message}")
48
- end
49
-
50
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] Connection closed')
51
- end
52
-
53
- private
54
-
55
- def attempt_connection
56
- return if @closed
57
- return if @connecting
58
-
59
- @connecting = true
60
- @connection_attempts += 1
61
- timestamp = Time.now.utc.iso8601(3)
62
- signature = generate_signature(timestamp)
63
-
64
- headers = {
65
- 'Accept' => 'text/event-stream',
66
- 'X_TIMESTAMP' => timestamp,
67
- 'X_SIGNATURE' => signature
68
- }
69
-
70
- ForestAdminAgent::Facades::Container.logger&.log(
71
- 'Debug',
72
- "[SSE Client] Connecting to #{@uri} (attempt ##{@connection_attempts})"
73
- )
74
-
75
- begin
76
- # Close existing client if any
77
- begin
78
- @client&.close
79
- rescue StandardError
80
- # Ignore close errors
81
- end
82
-
83
- @client = SSE::Client.new(@uri, headers: headers) do |client|
84
- client.on_event do |event|
85
- handle_event(event)
86
- end
87
-
88
- client.on_error do |err|
89
- handle_error_with_reconnect(err)
90
- end
91
- end
92
-
93
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] Connected successfully')
94
- rescue StandardError => e
95
- ForestAdminAgent::Facades::Container.logger&.log(
96
- 'Error',
97
- "[SSE Client] Failed to connect: #{e.class} - #{e.message}"
98
- )
99
- @connecting = false
100
- schedule_reconnect
101
- end
102
- end
103
-
104
- def handle_error_with_reconnect(err)
105
- # Ignore errors when client is intentionally closed
106
- return if @closed
107
-
108
- is_auth_error = false
109
- log_level = 'Warn'
110
-
111
- error_message = case err
112
- when SSE::Errors::HTTPStatusError
113
- # Extract more details from HTTP errors
114
- status = err.respond_to?(:status) ? err.status : 'unknown'
115
- body = err.respond_to?(:body) && !err.body.to_s.strip.empty? ? err.body : 'empty response'
116
- is_auth_error = status.to_s =~ /^(401|403)$/
117
-
118
- # Auth errors during reconnection are expected (server shutdown or credentials expiring)
119
- log_level = 'Debug' if is_auth_error
120
-
121
- "HTTP #{status} - #{body}"
122
- when EOFError, IOError
123
- # Connection lost is expected when server stops
124
- log_level = 'Debug'
125
- "Connection lost: #{err.class}"
126
- when StandardError
127
- "#{err.class} - #{err.message}"
128
- else
129
- err.to_s
130
- end
131
-
132
- ForestAdminAgent::Facades::Container.logger&.log(log_level, "[SSE Client] Error: #{error_message}")
133
-
134
- # Close client immediately to prevent ld-eventsource from reconnecting with stale credentials
135
- begin
136
- @client&.close
137
- rescue StandardError
138
- # Ignore close errors
139
- end
140
-
141
- # Reset connecting flag and schedule reconnection
142
- @connecting = false
143
-
144
- # For auth errors, increase attempt count to get longer backoff
145
- @connection_attempts += 2 if is_auth_error
146
-
147
- schedule_reconnect
148
- end
149
-
150
- def schedule_reconnect
151
- return if @closed
152
- return if @reconnect_thread&.alive?
153
-
154
- @reconnect_thread = Thread.new do
155
- delay = calculate_backoff_delay
156
- ForestAdminAgent::Facades::Container.logger&.log(
157
- 'Debug',
158
- "[SSE Client] Reconnecting in #{delay} seconds..."
159
- )
160
- sleep(delay)
161
- attempt_connection unless @closed
162
- end
163
- end
164
-
165
- def calculate_backoff_delay
166
- # Exponential backoff: 1, 2, 4, 8, 16, 30, 30, ...
167
- delay = INITIAL_BACKOFF_DELAY * (2**[@connection_attempts - 1, 0].max)
168
- [delay, MAX_BACKOFF_DELAY].min
169
- end
170
-
171
- def handle_event(event)
172
- type = event.type.to_s.strip
173
- data = event.data.to_s.strip
174
-
175
- case type
176
- when 'heartbeat'
177
- if @connecting
178
- @connecting = false
179
- @connection_attempts = 0
180
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] Connection stable')
181
- end
182
- when 'RpcServerStop'
183
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] RpcServerStop received')
184
- handle_rpc_stop
185
- else
186
- ForestAdminAgent::Facades::Container.logger&.log(
187
- 'Debug',
188
- "[SSE Client] Unknown event: #{type} with payload: #{data}"
189
- )
190
- end
191
- rescue StandardError => e
192
- ForestAdminAgent::Facades::Container.logger&.log(
193
- 'Error',
194
- "[SSE Client] Error handling event: #{e.class} - #{e.message}"
195
- )
196
- end
197
-
198
- def handle_rpc_stop
199
- @on_rpc_stop&.call
200
- rescue StandardError => e
201
- ForestAdminAgent::Facades::Container.logger&.log(
202
- 'Error',
203
- "[SSE Client] Error in RPC stop callback: #{e.class} - #{e.message}"
204
- )
205
- end
206
-
207
- def generate_signature(timestamp)
208
- OpenSSL::HMAC.hexdigest('SHA256', @auth_secret, timestamp)
209
- end
210
- end
211
- end
212
- end