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 +4 -4
- data/forest_admin_datasource_rpc.gemspec +0 -1
- data/lib/forest_admin_datasource_rpc/Utils/rpc_client.rb +90 -45
- data/lib/forest_admin_datasource_rpc/Utils/schema_polling_client.rb +219 -0
- data/lib/forest_admin_datasource_rpc/datasource.rb +6 -6
- data/lib/forest_admin_datasource_rpc/version.rb +1 -1
- data/lib/forest_admin_datasource_rpc.rb +38 -13
- metadata +3 -17
- data/lib/forest_admin_datasource_rpc/Utils/sse_client.rb +0 -212
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9485d38ceffa876811869166eb3b74209f0c1a9807c4a2b3571b1271a552d428
|
|
4
|
+
data.tar.gz: 4dff025fcea11c17531f412b37ae13dfa68485484aa74fd7ccfcf5c3b11fc829
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
return response.body if response.success?
|
|
92
|
+
return NotModified if response.status == HTTP_NOT_MODIFIED
|
|
68
93
|
|
|
69
|
-
response
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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,
|
|
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
|
-
@
|
|
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 @
|
|
62
|
-
ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource]
|
|
63
|
-
@
|
|
64
|
-
ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource]
|
|
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(
|
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
81
|
+
schema_polling.start
|
|
57
82
|
|
|
58
|
-
datasource = ForestAdminDatasourceRpc::Datasource.new(options, schema,
|
|
83
|
+
datasource = ForestAdminDatasourceRpc::Datasource.new(options, schema, schema_polling)
|
|
59
84
|
|
|
60
|
-
# Setup cleanup hooks for proper
|
|
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.
|
|
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-
|
|
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/
|
|
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
|