statelydb 0.11.0 → 0.12.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: 2a342b194da9de36c892bb4d9f7c15b5904b75a07c3a5d6b2a06c94c9df7615d
4
- data.tar.gz: b5d1c84628d6fff0c186efc30912b52c81e637427456647e7856099bf0218996
3
+ metadata.gz: 14c7769c562abe9ba0294a0d3ec4a1c7a620f9233f35209d39766331f3dd73e5
4
+ data.tar.gz: db2562de5584684ef9baf9de405b38112a43eb123aa3826ae4e563c6cfa0c1d0
5
5
  SHA512:
6
- metadata.gz: 92cc17e071ff1fd85f69c6a7716e123e81d5238727c8d09d9bf1f450e6082db65c4b724268cd4478489b532ab366a95208f8d81efc820fd697461daf5dcb740c
7
- data.tar.gz: 7b90b2148bfcf696c91ed701233731dc0c7e6c027967bf266aa2cecbc023acdb5bcc3269998f997c47112de10f43555182560656e4bd73fcd29bb92092b55ca2
6
+ metadata.gz: 4ba09f8776771bb4b6fc078e32255a272d986e6983737e1df9fe71bf95e685484ce6d20a91f16c96973bc89f7c9c79d1c0b9411f4a683430581bbc1e27a17468
7
+ data.tar.gz: cf612cf68f6c714ba7e79d328ab2b18236c711dbe806a6fb9714dd93eb163ac9899a415bb6c5d4861d24f103bc3844dff40cde27c4eca37eecf1ac3304298f93
@@ -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 [Endpoint] domain The domain 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
+ domain: "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(domain: domain, audience: audience, client_secret: client_secret,
38
+ 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 [Endpoint] domain The domain 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
+ domain: "https://oauth.stately.cloud",
65
+ audience: "api.stately.cloud",
66
+ client_secret: ENV.fetch("STATELY_CLIENT_SECRET"),
67
+ client_id: ENV.fetch("STATELY_CLIENT_ID")
68
+ )
69
+ super()
70
+ @client = Async::HTTP::Client.new(Async::HTTP::Endpoint.parse(domain))
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_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_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_secs.nil?
112
+ return "", false if @expires_at_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_secs = Time.now.to_i + new_expires_in_secs
159
+ if @expires_at_secs.nil? || new_expires_at_secs > @expires_at_secs
160
+
161
+ @access_token = new_access_token
162
+ @expires_at_secs = new_expires_at_secs
163
+ else
164
+
165
+ new_access_token = @access_token
166
+ new_expires_in_secs = @expires_at_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.0
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-04 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