statelydb 0.11.0 → 0.12.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a342b194da9de36c892bb4d9f7c15b5904b75a07c3a5d6b2a06c94c9df7615d
4
- data.tar.gz: b5d1c84628d6fff0c186efc30912b52c81e637427456647e7856099bf0218996
3
+ metadata.gz: 8446a58ed766b26aeb2effbbbe422d1b809eb9d1833f51c8b0d9309c7787e4a3
4
+ data.tar.gz: 1de0251f91a6bd26399ed57999f09d3f1c27f11faecbc9335216d3c986d58d81
5
5
  SHA512:
6
- metadata.gz: 92cc17e071ff1fd85f69c6a7716e123e81d5238727c8d09d9bf1f450e6082db65c4b724268cd4478489b532ab366a95208f8d81efc820fd697461daf5dcb740c
7
- data.tar.gz: 7b90b2148bfcf696c91ed701233731dc0c7e6c027967bf266aa2cecbc023acdb5bcc3269998f997c47112de10f43555182560656e4bd73fcd29bb92092b55ca2
6
+ metadata.gz: 7194ac043df22729233b6d76e1517e3a34710d68f9c9d486aa1f849489c9bab435f767a1a805287e7cf85d3324bfedce744339aabdb3c03a6ff6a793aaa36b84
7
+ data.tar.gz: 02c00886f4704f8d0af5f8a7d885a906034238d924ec30c2fb46543bbce1a8bc646e64ceea8803604b0cb41fa87878a186968e901b320e6bfcf455d4ab8a73cd
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "async"
4
+ require "async/actor"
4
5
  require "async/http/internet"
5
6
  require "async/semaphore"
6
7
  require "json"
@@ -12,6 +13,7 @@ LOGGER = Logger.new($stdout)
12
13
  LOGGER.level = Logger::WARN
13
14
  DEFAULT_GRANT_TYPE = "client_credentials"
14
15
 
16
+ # A module for Stately Cloud auth code
15
17
  module StatelyDB
16
18
  module Common
17
19
  # A module for Stately Cloud auth code
@@ -21,102 +23,183 @@ module StatelyDB
21
23
  # It will default to using the values of `STATELY_CLIENT_ID` and `STATELY_CLIENT_SECRET` if
22
24
  # no credentials are explicitly passed and will throw an error if none are found.
23
25
  class Auth0TokenProvider < TokenProvider
24
- # @param [String] auth_url The URL of the OAuth server
26
+ # @param [String] origin The origin of the OAuth server
25
27
  # @param [String] audience The OAuth Audience for the token
26
28
  # @param [String] client_secret The StatelyDB client secret credential
27
29
  # @param [String] client_id The StatelyDB client ID credential
28
30
  def initialize(
29
- auth_url: "https://oauth.stately.cloud",
31
+ origin: "https://oauth.stately.cloud",
30
32
  audience: "api.stately.cloud",
31
33
  client_secret: ENV.fetch("STATELY_CLIENT_SECRET"),
32
34
  client_id: ENV.fetch("STATELY_CLIENT_ID")
33
35
  )
34
36
  super()
35
- @client_id = client_id
36
- @client_secret = client_secret
37
- @audience = audience
38
- @auth_url = "#{auth_url}/oauth/token"
39
- @access_token = nil
40
- @pending_refresh = nil
41
- @timer = nil
42
-
43
- Async do |_task|
44
- refresh_token
45
- end
46
-
47
- # need a weak ref to ourself or the GC will never run the finalizer
48
- ObjectSpace.define_finalizer(WeakRef.new(self), finalize)
37
+ @actor = Async::Actor.new(Actor.new(origin: origin, audience: audience,
38
+ client_secret: client_secret, client_id: client_id))
39
+ # this initialization cannot happen in the constructor because it is async and must run on the event loop
40
+ # which is not available in the constructor
41
+ @actor.init
49
42
  end
50
43
 
51
- # finalizer kills the thread running the timer if one exists
52
- # @return [Proc] The finalizer proc
53
- def finalize
54
- proc {
55
- Thread.kill(@timer) unless @timer.nil?
56
- }
44
+ # Close the token provider and kill any background operations
45
+ # This just invokes the close method on the actor which should do the cleanup
46
+ def close
47
+ @actor.close
57
48
  end
58
49
 
59
50
  # Get the current access token
60
51
  # @return [String] The current access token
61
- def access_token
62
- # TODO: - check whether or not the GIL is enough to make this threadsafe
63
- @access_token || refresh_token
52
+ def get_token(force: false)
53
+ @actor.get_token(force: force)
64
54
  end
65
55
 
66
- private
67
-
68
- # Refresh the access token
69
- # @return [void]
70
- def refresh_token
71
- # never run more than one at a time.
72
- @pending_refresh ||= refresh_token_impl
73
- # many threads all wait on the same task here.
74
- # I wrote a test to check this is possible
75
- @pending_refresh.wait
76
- # multiple people will all set this to nil after
77
- # they are done waiting but I don't think i can put this inside
78
- # refresh_token_impl. It seems harmless because of the GIL?
79
- @pending_refresh = nil
80
- end
56
+ # Actor for managing the token refresh
57
+ # This is designed to be used with Async::Actor and run on a dedicated thread.
58
+ class Actor
59
+ # @param [String] origin The origin of the OAuth server
60
+ # @param [String] audience The OAuth Audience for the token
61
+ # @param [String] client_secret The StatelyDB client secret credential
62
+ # @param [String] client_id The StatelyDB client ID credential
63
+ def initialize(
64
+ origin:,
65
+ audience:,
66
+ client_secret:,
67
+ client_id:
68
+ )
69
+ super()
70
+ @client = Async::HTTP::Client.new(Async::HTTP::Endpoint.parse(origin))
71
+ @client_id = client_id
72
+ @client_secret = client_secret
73
+ @audience = audience
81
74
 
82
- # Refresh the access token implementation
83
- # @return [String] The new access token
84
- def refresh_token_impl
85
- Async do
86
- client = Async::HTTP::Internet.new
87
- headers = [["content-type", "application/json"]]
88
- data = { "client_id" => @client_id, client_secret: @client_secret, audience: @audience,
89
- grant_type: DEFAULT_GRANT_TYPE }
90
- body = [JSON.dump(data)]
91
-
92
- resp = client.post(@auth_url, headers, body)
93
- resp_data = JSON.parse(resp.read)
94
- raise "Auth request failed: #{resp_data}" if resp.status != 200
95
-
96
- @access_token = resp_data["access_token"]
97
-
98
- # do this on a thread or else the sleep
99
- # will block the event loop.
100
- # there is no non-blocking sleep in ruby.
101
- # skip this if we have a pending timer thread already
102
- @timer = Thread.new do
103
- # Calculate a random multiplier between 0.3 and 0.8 to to apply to the expiry
75
+ @access_token = nil
76
+ @expires_at_unix_secs = nil
77
+ @pending_refresh = nil
78
+ end
79
+
80
+ # Initialize the actor. This runs on the actor thread which means
81
+ # we can dispatch async operations here.
82
+ def init
83
+ refresh_token
84
+ end
85
+
86
+ # Close the token provider and kill any background operations
87
+ def close
88
+ @scheduled&.stop
89
+ @client&.close
90
+ end
91
+
92
+ # Get the current access token
93
+ # @param [Boolean] force Whether to force a refresh of the token
94
+ # @return [String] The current access token
95
+ def get_token(force: false)
96
+ if force
97
+ @access_token = nil
98
+ @expires_at_unix_secs = nil
99
+ else
100
+ token, ok = valid_access_token
101
+ return token if ok
102
+ end
103
+
104
+ refresh_token.wait
105
+ end
106
+
107
+ # Get the current access token and whether it is valid
108
+ # @return [Array] The current access token and whether it is valid
109
+ def valid_access_token
110
+ return "", false if @access_token.nil?
111
+ return "", false if @expires_at_unix_secs.nil?
112
+ return "", false if @expires_at_unix_secs < Time.now.to_i
113
+
114
+ [@access_token, true]
115
+ end
116
+
117
+ # Refresh the access token
118
+ # @return [string] The new access token
119
+ def refresh_token
120
+ Async do
121
+ # we use an Async::Condition to dedupe multiple requests here
122
+ # if the condition exists, we wait on it to complete
123
+ # otherwise we create a condition, make the request, then signal the condition with the result
124
+ # If there is an error then we signal that instead so we can raise it for the waiters.
125
+ if @pending_refresh.nil?
126
+ begin
127
+ @pending_refresh = Async::Condition.new
128
+ new_access_token = refresh_token_impl.wait
129
+ # now broadcast the new token to any waiters
130
+ @pending_refresh.signal(new_access_token)
131
+ new_access_token
132
+ rescue StandardError => e
133
+ @pending_refresh.signal(e)
134
+ raise e
135
+ ensure
136
+ # delete the condition to restart the process
137
+ @pending_refresh = nil
138
+ end
139
+ else
140
+ res = @pending_refresh.wait
141
+ # if the refresh result is an error, re-raise it.
142
+ # otherwise return the token
143
+ raise res if res.is_a?(StandardError)
144
+
145
+ res
146
+ end
147
+ end
148
+ end
149
+
150
+ # Refresh the access token implementation
151
+ # @return [String] The new access token
152
+ def refresh_token_impl
153
+ Async do
154
+ resp_data = make_auth0_request
155
+
156
+ new_access_token = resp_data["access_token"]
157
+ new_expires_in_secs = resp_data["expires_in"]
158
+ new_expires_at_unix_secs = Time.now.to_i + new_expires_in_secs
159
+ if @expires_at_unix_secs.nil? || new_expires_at_unix_secs > @expires_at_unix_secs
160
+
161
+ @access_token = new_access_token
162
+ @expires_at_unix_secs = new_expires_at_unix_secs
163
+ else
164
+
165
+ new_access_token = @access_token
166
+ new_expires_in_secs = @expires_at_unix_secs - Time.now.to_i
167
+ end
168
+
169
+ # Schedule a refresh of the token ahead of the expiry time
170
+ # Calculate a random multiplier between 0.9 and 0.95 to to apply to the expiry
104
171
  # so that we refresh in the background ahead of expiration, but avoid
105
172
  # multiple processes hammering the service at the same time.
106
- jitter = (Random.rand * 0.5) + 0.3
107
- delay = resp_data["expires_in"] * jitter
108
- sleep(delay)
109
- refresh_token
173
+ jitter = (Random.rand * 0.05) + 0.9
174
+ delay_secs = new_expires_in_secs * jitter
175
+
176
+ # do this on the fiber scheduler (the root scheduler) to avoid infinite recursion
177
+ @scheduled ||= Fiber.scheduler.async do
178
+ # Kernel.sleep is non-blocking if Ruby 3.1+ and Async 2+
179
+ # https://github.com/socketry/async/issues/305#issuecomment-1945188193
180
+ sleep(delay_secs)
181
+ refresh_token
182
+ @scheduled = nil
183
+ end
184
+
185
+ new_access_token
186
+ end
187
+ end
188
+
189
+ def make_auth0_request
190
+ headers = [["content-type", "application/json"]]
191
+ body = JSON.dump({ "client_id" => @client_id, client_secret: @client_secret, audience: @audience,
192
+ grant_type: DEFAULT_GRANT_TYPE })
193
+ Sync do
194
+ # TODO: Wrap this in a retry loop and parse errors like we
195
+ # do in the Go SDK.
196
+ response = @client.post("/oauth/token", headers, body)
197
+ raise "Auth request failed" if response.status != 200
198
+
199
+ JSON.parse(response.read)
200
+ ensure
201
+ response&.close
110
202
  end
111
- @pending_refresh = nil
112
- resp_data["access_token"]
113
- rescue StandardError => e
114
- # set the token to nil so that it will
115
- # be refreshed on the next get
116
- @access_token = nil
117
- LOGGER.warn(e)
118
- ensure
119
- client.close
120
203
  end
121
204
  end
122
205
  end
@@ -74,7 +74,7 @@ module StatelyDB
74
74
  # @return [void]
75
75
  # @api private
76
76
  def add_jwt_to_grpc_request(metadata:)
77
- metadata["authorization"] = "Bearer #{@token_provider.access_token}"
77
+ metadata["authorization"] = "Bearer #{@token_provider.get_token}"
78
78
  end
79
79
  end
80
80
  end
@@ -8,8 +8,14 @@ module StatelyDB
8
8
  # for individual token provider implementations
9
9
  class TokenProvider
10
10
  # Get the current access token
11
+ # @param [Boolean] force Whether to force a refresh of the token
11
12
  # @return [String] The current access token
12
- def access_token
13
+ def get_token(force: false) # rubocop:disable Lint/UnusedMethodArgument
14
+ raise "Not Implemented"
15
+ end
16
+
17
+ # Close the token provider and kill any background operations
18
+ def close
13
19
  raise "Not Implemented"
14
20
  end
15
21
  end
data/lib/statelydb.rb CHANGED
@@ -50,18 +50,24 @@ module StatelyDB
50
50
  end
51
51
 
52
52
  endpoint = self.class.make_endpoint(endpoint:, region:)
53
- channel = Common::Net.new_channel(endpoint:)
53
+ @channel = Common::Net.new_channel(endpoint:)
54
+ @token_provider = token_provider
54
55
 
55
56
  auth_interceptor = Common::Auth::Interceptor.new(token_provider:)
56
57
  error_interceptor = Common::ErrorInterceptor.new
57
58
 
58
- @stub = Stately::Db::DatabaseService::Stub.new(nil, nil, channel_override: channel,
59
+ @stub = Stately::Db::DatabaseService::Stub.new(nil, nil, channel_override: @channel,
59
60
  interceptors: [error_interceptor, auth_interceptor])
60
61
  @store_id = store_id.to_i
61
62
  @schema = schema
62
63
  @allow_stale = false
63
64
  end
64
65
 
66
+ def close
67
+ @channel&.close
68
+ @token_provider&.close
69
+ end
70
+
65
71
  # Set whether to allow stale results for all operations with this client. This produces a new client
66
72
  # with the allow_stale flag set.
67
73
  # @param allow_stale [Boolean] whether to allow stale results
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statelydb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stately Cloud, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-11-23 00:00:00.000000000 Z
11
+ date: 2024-12-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -16,28 +16,42 @@ dependencies:
16
16
  requirements:
17
17
  - - '='
18
18
  - !ruby/object:Gem::Version
19
- version: 2.10.1
19
+ version: 2.21.1
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - '='
25
25
  - !ruby/object:Gem::Version
26
- version: 2.10.1
26
+ version: 2.21.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: async-actor
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '='
32
+ - !ruby/object:Gem::Version
33
+ version: 0.1.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '='
39
+ - !ruby/object:Gem::Version
40
+ version: 0.1.1
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: async-http
29
43
  requirement: !ruby/object:Gem::Requirement
30
44
  requirements:
31
45
  - - '='
32
46
  - !ruby/object:Gem::Version
33
- version: 0.64.0
47
+ version: 0.85.0
34
48
  type: :runtime
35
49
  prerelease: false
36
50
  version_requirements: !ruby/object:Gem::Requirement
37
51
  requirements:
38
52
  - - '='
39
53
  - !ruby/object:Gem::Version
40
- version: 0.64.0
54
+ version: 0.85.0
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: grpc
43
57
  requirement: !ruby/object:Gem::Requirement