atlas_rb 1.14.0 → 1.15.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/.version +1 -1
- data/CHANGELOG.md +52 -0
- data/Gemfile.lock +8 -1
- data/README.md +36 -0
- data/lib/atlas_rb/configuration.rb +23 -0
- data/lib/atlas_rb/faraday_helper.rb +96 -58
- data/lib/atlas_rb/transport.rb +221 -0
- data/lib/atlas_rb.rb +4 -0
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a32e3df39d1a5f9648e47a42b88be5deff1b28dc696f9575a073baac4b9fa58
|
|
4
|
+
data.tar.gz: cb224b216444752165a39f242120e478cbe03c4a2613e36b19665d1897ec59b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 43cf01a77168ae4ae78de02bf26403e9c116b63f18d685adc7bf82275124aaa498129ea1a8dad9c4867330b455c236f68f20b05763743be8b4db97ceef0b8324
|
|
7
|
+
data.tar.gz: 6fb09278babb33a465f090e32b0616649bddd4fb043337cbeace8c6c0b7f83ff0cde6710f01eecec69e91d8c65db2d58c67683a7c55496078f090312d7f77317
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.15.0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.15.0
|
|
4
|
+
|
|
5
|
+
### Changed — the transport reuses connections instead of opening one per request
|
|
6
|
+
|
|
7
|
+
`FaradayHelper` built a fresh `Faraday.new` for every call, and Faraday's
|
|
8
|
+
default `net_http` adapter wraps each request in its own `Net::HTTP#start`
|
|
9
|
+
block and closes the socket after it. So every Atlas call paid a TCP handshake,
|
|
10
|
+
and under TLS a full TLS handshake on top of that.
|
|
11
|
+
|
|
12
|
+
The three builders now hand back an `AtlasRb::Transport::Proxy` over a shared,
|
|
13
|
+
pooled connection — one per shape (`:json`, `:multipart`, `:system`) and base
|
|
14
|
+
URL — using the `net_http_persistent` adapter. The proxy answers `get`, `post`,
|
|
15
|
+
`patch`, `put` and `delete` like a `Faraday::Connection` and forwards a block to
|
|
16
|
+
the request, so no call site changes.
|
|
17
|
+
|
|
18
|
+
Measured on loopback inside the container, 100 requests per run, self-signed
|
|
19
|
+
ECDSA P-256:
|
|
20
|
+
|
|
21
|
+
| transport | before | after |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| plain HTTP | 1.21ms/req | 0.75ms/req |
|
|
24
|
+
| TLS | 3.07ms/req | 0.31ms/req |
|
|
25
|
+
|
|
26
|
+
Add a round trip each for TCP and TLS over a real network. The largest win is
|
|
27
|
+
a sequential migration script, where the saving is elapsed time and nothing
|
|
28
|
+
hides it; it also removes the `TIME_WAIT` pressure of a socket per request.
|
|
29
|
+
|
|
30
|
+
Per-request state — the signed assertion, the `Idempotency-Key`, query params —
|
|
31
|
+
now rides on the request rather than being baked into the connection, which is
|
|
32
|
+
what makes a connection safe to share. The pool is process-wide, not
|
|
33
|
+
thread-local, so a fan-out on short-lived threads reuses it.
|
|
34
|
+
|
|
35
|
+
Two knobs, both optional:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
AtlasRb.configure do |config|
|
|
39
|
+
config.connection_pool_size = 16 # sockets per Atlas host
|
|
40
|
+
config.connection_max_requests = nil # cap only if a proxy in front caps it
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`AtlasRb::Transport.reset_connections!` closes every pooled socket and drops the
|
|
45
|
+
cached connections. A suite that boots and tears down a real server should call
|
|
46
|
+
it between examples.
|
|
47
|
+
|
|
48
|
+
### Fixed — the signing key is parsed once, not per request
|
|
49
|
+
|
|
50
|
+
`assertion_signing_key` is configured as a callable returning a PEM, and
|
|
51
|
+
`OpenSSL::PKey.read` ran on every request: 0.367ms against the 0.072ms ES256
|
|
52
|
+
signature it exists to produce. The parse is now cached and re-run only when the
|
|
53
|
+
PEM changes.
|
|
54
|
+
|
|
3
55
|
## 1.14.0
|
|
4
56
|
|
|
5
57
|
### Added — `Blob.find_many_versions`, the batch version-history read
|
data/Gemfile.lock
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
atlas_rb (1.
|
|
4
|
+
atlas_rb (1.15.0)
|
|
5
5
|
faraday (~> 2.7)
|
|
6
6
|
faraday-follow_redirects (~> 0.3.0)
|
|
7
7
|
faraday-multipart (~> 1)
|
|
8
|
+
faraday-net_http_persistent (~> 2.3)
|
|
8
9
|
hashie (~> 5.0)
|
|
9
10
|
jwt (~> 2.7)
|
|
10
11
|
|
|
@@ -12,6 +13,7 @@ GEM
|
|
|
12
13
|
remote: https://rubygems.org/
|
|
13
14
|
specs:
|
|
14
15
|
base64 (0.3.0)
|
|
16
|
+
connection_pool (2.5.5)
|
|
15
17
|
diff-lcs (1.6.1)
|
|
16
18
|
faraday (2.14.3)
|
|
17
19
|
faraday-net_http (>= 2.0, < 3.5)
|
|
@@ -23,6 +25,9 @@ GEM
|
|
|
23
25
|
multipart-post (~> 2.0)
|
|
24
26
|
faraday-net_http (3.4.4)
|
|
25
27
|
net-http (~> 0.5)
|
|
28
|
+
faraday-net_http_persistent (2.3.1)
|
|
29
|
+
faraday (~> 2.5)
|
|
30
|
+
net-http-persistent (>= 4.0.4, < 5)
|
|
26
31
|
hashie (5.1.0)
|
|
27
32
|
logger
|
|
28
33
|
json (2.21.2)
|
|
@@ -32,6 +37,8 @@ GEM
|
|
|
32
37
|
multipart-post (2.4.1)
|
|
33
38
|
net-http (0.9.1)
|
|
34
39
|
uri (>= 0.11.1)
|
|
40
|
+
net-http-persistent (4.0.8)
|
|
41
|
+
connection_pool (>= 2.2.4, < 4)
|
|
35
42
|
rake (13.2.1)
|
|
36
43
|
rspec (3.13.0)
|
|
37
44
|
rspec-core (~> 3.13.0)
|
data/README.md
CHANGED
|
@@ -156,6 +156,42 @@ atlas_system_token: <token-Atlas-side-recognises-as-:system>
|
|
|
156
156
|
The system NUID itself is hardcoded as `AtlasRb::System::NUID =
|
|
157
157
|
"000000000"`, matching Atlas's seeded `:system` fixture row.
|
|
158
158
|
|
|
159
|
+
### Connection reuse
|
|
160
|
+
|
|
161
|
+
The gem keeps one pooled connection per shape (`:json`, `:multipart`,
|
|
162
|
+
`:system`) and base URL, so a run of Atlas calls reuses its sockets rather than
|
|
163
|
+
opening one per request. Over plain HTTP that saves a TCP handshake per call;
|
|
164
|
+
under TLS it saves the TLS handshake too, which is where the cost actually
|
|
165
|
+
sits — on loopback, 3.07ms per request against 0.31ms. Over a real network add
|
|
166
|
+
a round trip each for TCP and TLS.
|
|
167
|
+
|
|
168
|
+
Nothing is required to get this. The pool is process-wide rather than
|
|
169
|
+
thread-local, so a host that fans requests out on short-lived threads reuses it
|
|
170
|
+
as well.
|
|
171
|
+
|
|
172
|
+
Two knobs, both optional:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
AtlasRb.configure do |config|
|
|
176
|
+
# Sockets kept open per Atlas host. Size it to your own concurrency — a host
|
|
177
|
+
# that fans out four reads per request thread wants four times its thread
|
|
178
|
+
# count, plus headroom. Defaults to 16.
|
|
179
|
+
config.connection_pool_size = 16
|
|
180
|
+
|
|
181
|
+
# Requests to send on one socket before replacing it. Leave nil against Puma.
|
|
182
|
+
# Set it when something between you and Puma caps requests per connection,
|
|
183
|
+
# because the request after the cap fails with ECONNRESET.
|
|
184
|
+
config.connection_max_requests = nil
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
A test suite that boots and tears down a real Atlas server should close the
|
|
189
|
+
pool between examples, or a socket from one example stays open into the next:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
config.after(:each, :atlas_rb_server) { AtlasRb::Transport.reset_connections! }
|
|
193
|
+
```
|
|
194
|
+
|
|
159
195
|
## Resource hierarchy
|
|
160
196
|
|
|
161
197
|
```
|
|
@@ -76,5 +76,28 @@ module AtlasRb
|
|
|
76
76
|
# Atlas selects the matching public key. Value or callable. Required when
|
|
77
77
|
# {#assertion_signing_key} is set.
|
|
78
78
|
attr_accessor :assertion_signing_kid
|
|
79
|
+
|
|
80
|
+
# Sockets the transport keeps open per Atlas host. Size it to the host's
|
|
81
|
+
# own concurrency — a consumer that fans out N reads per request thread
|
|
82
|
+
# wants N times its thread count, plus headroom — rather than to the
|
|
83
|
+
# `net-http-persistent` default of 256, which is a file-descriptor budget
|
|
84
|
+
# rather than a considered number. `nil` takes
|
|
85
|
+
# {AtlasRb::Transport::DEFAULT_POOL_SIZE}.
|
|
86
|
+
#
|
|
87
|
+
# Read when a connection is first built, so set it before the first Atlas
|
|
88
|
+
# call; changing it later has no effect until
|
|
89
|
+
# {AtlasRb::Transport.reset_connections!}.
|
|
90
|
+
#
|
|
91
|
+
# @return [Integer, nil]
|
|
92
|
+
attr_accessor :connection_pool_size
|
|
93
|
+
|
|
94
|
+
# Requests to send on one pooled socket before replacing it. `nil` (the
|
|
95
|
+
# default) means no cap, which is what a direct connection to Puma wants.
|
|
96
|
+
# Set it when something between the client and Puma caps requests per
|
|
97
|
+
# connection, because the request after that cap fails with `ECONNRESET`
|
|
98
|
+
# rather than reconnecting.
|
|
99
|
+
#
|
|
100
|
+
# @return [Integer, nil]
|
|
101
|
+
attr_accessor :connection_max_requests
|
|
79
102
|
end
|
|
80
103
|
end
|
|
@@ -51,6 +51,16 @@ module AtlasRb
|
|
|
51
51
|
# attached the emit degrades to a listener lookup + `yield`, so it is safe to
|
|
52
52
|
# leave in the stack permanently — opt-in lives entirely on the consumer side.
|
|
53
53
|
#
|
|
54
|
+
# ## Connection reuse
|
|
55
|
+
#
|
|
56
|
+
# The three builders do not return a `Faraday::Connection`. They return an
|
|
57
|
+
# {AtlasRb::Transport::Proxy} over a shared, cached connection, so Atlas calls
|
|
58
|
+
# reuse sockets instead of paying a TCP — and under TLS a full TLS —
|
|
59
|
+
# handshake each time. The proxy answers the same verbs and forwards a block,
|
|
60
|
+
# so call sites are unchanged. See {AtlasRb::Transport} for why the
|
|
61
|
+
# connection has to be cached for keep-alive to happen at all, and
|
|
62
|
+
# {AtlasRb::Transport.reset_connections!} for the test-suite teardown hook.
|
|
63
|
+
#
|
|
54
64
|
# The module is mixed in via `extend`, so its methods become class methods on
|
|
55
65
|
# the host (e.g. `AtlasRb::Work.connection({})`).
|
|
56
66
|
module FaradayHelper
|
|
@@ -91,8 +101,10 @@ module AtlasRb
|
|
|
91
101
|
# signs when it can but sends no `Authorization` header otherwise, for the
|
|
92
102
|
# handful of endpoints Atlas serves with auth skipped (currently only
|
|
93
103
|
# `GET /reset`).
|
|
94
|
-
# @return [
|
|
95
|
-
#
|
|
104
|
+
# @return [AtlasRb::Transport::Proxy] a per-request view onto the shared
|
|
105
|
+
# JSON connection, which follows redirects and pools its sockets. It
|
|
106
|
+
# answers `get` / `post` / `patch` / `put` / `delete` like a
|
|
107
|
+
# `Faraday::Connection` and forwards a block to the request.
|
|
96
108
|
#
|
|
97
109
|
# @example Fetching a community
|
|
98
110
|
# AtlasRb::Community.connection({}).get('/communities/abc123')
|
|
@@ -101,22 +113,23 @@ module AtlasRb
|
|
|
101
113
|
.merge("Content-Type" => "application/json")
|
|
102
114
|
headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
103
115
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
f.adapter Faraday.default_adapter
|
|
116
|
+
url = ENV.fetch("ATLAS_URL", nil)
|
|
117
|
+
conn = AtlasRb::Transport.connection_for([:json, url]) do
|
|
118
|
+
Faraday.new(url: url) do |f|
|
|
119
|
+
instrument(f)
|
|
120
|
+
f.use AtlasRb::Middleware::RaiseOnStaleResource
|
|
121
|
+
f.use AtlasRb::Middleware::RaiseOnResourceError
|
|
122
|
+
# Path-independent, unlike the pair above: a maintenance window refuses
|
|
123
|
+
# writes on EVERY path, and a 503 reaches neither of them. Registered on
|
|
124
|
+
# all three connection builders — a write that slips past it silently
|
|
125
|
+
# unwraps nil and reports success.
|
|
126
|
+
f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
|
|
127
|
+
f.response :follow_redirects
|
|
128
|
+
persistent_adapter(f)
|
|
129
|
+
end
|
|
119
130
|
end
|
|
131
|
+
|
|
132
|
+
AtlasRb::Transport::Proxy.new(conn, headers, params)
|
|
120
133
|
end
|
|
121
134
|
|
|
122
135
|
# Build a multipart Faraday connection used for binary and XML uploads.
|
|
@@ -134,7 +147,8 @@ module AtlasRb
|
|
|
134
147
|
# @param idempotency_key [String, nil] optional UUID to send in the
|
|
135
148
|
# `Idempotency-Key` header. See {#connection} for semantics; the
|
|
136
149
|
# `POST /files` (Blob) create flow uses this transport.
|
|
137
|
-
# @return [
|
|
150
|
+
# @return [AtlasRb::Transport::Proxy] a per-request view onto the shared
|
|
151
|
+
# multipart connection.
|
|
138
152
|
#
|
|
139
153
|
# @example Posting a binary blob
|
|
140
154
|
# payload = {
|
|
@@ -148,24 +162,27 @@ module AtlasRb
|
|
|
148
162
|
headers = auth_headers(nuid, on_behalf_of, account: account)
|
|
149
163
|
headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
150
164
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
url = ENV.fetch("ATLAS_URL", nil)
|
|
166
|
+
conn = AtlasRb::Transport.connection_for([:multipart, url]) do
|
|
167
|
+
Faraday.new(url: url) do |f|
|
|
168
|
+
instrument(f)
|
|
169
|
+
f.use AtlasRb::Middleware::RaiseOnStaleResource
|
|
170
|
+
# Translate Atlas's verify-on-ingest 422 (fixity_mismatch /
|
|
171
|
+
# unsupported_digest_algorithm) into a typed FixityMismatchError —
|
|
172
|
+
# the JSON-connection path already carries this; uploads need it too.
|
|
173
|
+
f.use AtlasRb::Middleware::RaiseOnResourceError
|
|
174
|
+
# Path-independent, unlike the pair above: a maintenance window refuses
|
|
175
|
+
# writes on EVERY path, and a 503 reaches neither of them. Registered on
|
|
176
|
+
# all three connection builders — a write that slips past it silently
|
|
177
|
+
# unwraps nil and reports success.
|
|
178
|
+
f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
|
|
179
|
+
f.request :multipart
|
|
180
|
+
f.request :url_encoded
|
|
181
|
+
persistent_adapter(f)
|
|
182
|
+
end
|
|
168
183
|
end
|
|
184
|
+
|
|
185
|
+
AtlasRb::Transport::Proxy.new(conn, headers)
|
|
169
186
|
end
|
|
170
187
|
|
|
171
188
|
# Build a streaming multipart FilePart for `blob_path`, run the request
|
|
@@ -176,10 +193,12 @@ module AtlasRb
|
|
|
176
193
|
# exhausts FDs across a TB migration of millions of files.
|
|
177
194
|
#
|
|
178
195
|
# Streaming/memory: faraday-multipart wraps the part in a streaming
|
|
179
|
-
# CompositeReadIO and the
|
|
196
|
+
# CompositeReadIO and the pinned net_http_persistent adapter sends it via
|
|
180
197
|
# `request.body_stream` (Content-Length known), so a multi-GB file uploads
|
|
181
|
-
# without being buffered into a String in memory.
|
|
182
|
-
#
|
|
198
|
+
# without being buffered into a String in memory. The adapter is pinned by
|
|
199
|
+
# {#persistent_adapter} rather than read from `Faraday.default_adapter`, so
|
|
200
|
+
# a host app's own default cannot swap a buffering adapter in underneath
|
|
201
|
+
# this.
|
|
183
202
|
#
|
|
184
203
|
# @param blob_path [String] path to the binary on disk.
|
|
185
204
|
# @yieldparam part [Faraday::Multipart::FilePart] the streaming part.
|
|
@@ -208,7 +227,8 @@ module AtlasRb
|
|
|
208
227
|
# @param params [Hash] query-string / body params.
|
|
209
228
|
# @param on_behalf_of [String, nil] optional NUID sent as a plain (not
|
|
210
229
|
# signed) `On-Behalf-Of` header — this path is already backend-only.
|
|
211
|
-
# @return [
|
|
230
|
+
# @return [AtlasRb::Transport::Proxy] a per-request view onto the shared
|
|
231
|
+
# system-authenticated connection.
|
|
212
232
|
# @raise [RuntimeError] if the credential is not configured.
|
|
213
233
|
# @raise [NameError] if `Rails` is not loaded (the gem assumes a
|
|
214
234
|
# Rails host for system-path calls).
|
|
@@ -223,28 +243,42 @@ module AtlasRb
|
|
|
223
243
|
}
|
|
224
244
|
headers["On-Behalf-Of"] = "NUID #{on_behalf_of}" if on_behalf_of
|
|
225
245
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
f.adapter Faraday.default_adapter
|
|
246
|
+
url = ENV.fetch("ATLAS_URL", nil)
|
|
247
|
+
conn = AtlasRb::Transport.connection_for([:system, url]) do
|
|
248
|
+
Faraday.new(url: url) do |f|
|
|
249
|
+
instrument(f)
|
|
250
|
+
# Narrowly path-scoped (see each class) — a no-op for System::User /
|
|
251
|
+
# System::Token, and gives System::Work the same typed errors as #connection.
|
|
252
|
+
f.use AtlasRb::Middleware::RaiseOnStaleResource
|
|
253
|
+
f.use AtlasRb::Middleware::RaiseOnResourceError
|
|
254
|
+
# Path-independent, unlike the pair above: a maintenance window refuses
|
|
255
|
+
# writes on EVERY path, and a 503 reaches neither of them. Registered on
|
|
256
|
+
# all three connection builders — a write that slips past it silently
|
|
257
|
+
# unwraps nil and reports success.
|
|
258
|
+
f.use AtlasRb::Middleware::RaiseOnReadOnlyMode
|
|
259
|
+
f.response :follow_redirects
|
|
260
|
+
persistent_adapter(f)
|
|
261
|
+
end
|
|
243
262
|
end
|
|
263
|
+
|
|
264
|
+
AtlasRb::Transport::Proxy.new(conn, headers, params)
|
|
244
265
|
end
|
|
245
266
|
|
|
246
267
|
private
|
|
247
268
|
|
|
269
|
+
# Pin the pooling adapter on every builder. Pinned rather than taking
|
|
270
|
+
# `Faraday.default_adapter`, because the saving depends on which adapter is
|
|
271
|
+
# in use: the default `net_http` wraps each request in its own
|
|
272
|
+
# `Net::HTTP#start` block and closes the socket after it, by design, so no
|
|
273
|
+
# amount of connection caching reuses anything under it. Pinning also means
|
|
274
|
+
# the streaming upload path no longer depends on what the host app happens
|
|
275
|
+
# to have set as its Faraday default.
|
|
276
|
+
def persistent_adapter(builder)
|
|
277
|
+
builder.adapter :net_http_persistent, pool_size: AtlasRb::Transport.pool_size do |http|
|
|
278
|
+
AtlasRb::Transport.configure_persistent(http)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
248
282
|
# Register Faraday's instrumentation middleware as the OUTERMOST handler so
|
|
249
283
|
# each logical call emits exactly one {INSTRUMENTATION_EVENT} — a redirect
|
|
250
284
|
# hop (e.g. the `/resources/:noid` resolver) is bracketed with the request
|
|
@@ -318,12 +352,16 @@ module AtlasRb
|
|
|
318
352
|
|
|
319
353
|
# Resolve the configured signing key to an OpenSSL::PKey, or nil if signing
|
|
320
354
|
# is not configured. Accepts a callable (resolved per request), a PEM
|
|
321
|
-
# string
|
|
355
|
+
# string, or an already-built key. A PEM is parsed through
|
|
356
|
+
# {AtlasRb::Transport.parsed_key}, because parsing costs several times the
|
|
357
|
+
# signature it exists to produce and the key rarely changes.
|
|
322
358
|
def assertion_signing_key
|
|
323
359
|
raw = config_value(AtlasRb.config.assertion_signing_key)
|
|
324
360
|
return nil if raw.nil?
|
|
325
361
|
|
|
326
|
-
raw.is_a?(OpenSSL::PKey::PKey)
|
|
362
|
+
return raw if raw.is_a?(OpenSSL::PKey::PKey)
|
|
363
|
+
|
|
364
|
+
AtlasRb::Transport.parsed_key(raw)
|
|
327
365
|
end
|
|
328
366
|
|
|
329
367
|
def assertion_signing_kid
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtlasRb
|
|
4
|
+
# Process-wide cache of Faraday connections, so Atlas calls reuse sockets
|
|
5
|
+
# instead of paying a TCP (and, under TLS, a full TLS) handshake per request.
|
|
6
|
+
#
|
|
7
|
+
# ## Why a cache is needed at all
|
|
8
|
+
#
|
|
9
|
+
# Keep-alive is what saves the handshake, and keep-alive needs two things
|
|
10
|
+
# that a per-request `Faraday.new` cannot give it. The pool lives on the
|
|
11
|
+
# adapter instance, and Faraday builds a new adapter for every connection —
|
|
12
|
+
# so a connection built per request gets a fresh, empty pool and reuses
|
|
13
|
+
# nothing. The socket has to outlive the request, which means the connection
|
|
14
|
+
# holding it has to as well.
|
|
15
|
+
#
|
|
16
|
+
# The cache is keyed on connection *shape* (`:json`, `:multipart`,
|
|
17
|
+
# `:system`) and base URL, because those are the only things that vary at
|
|
18
|
+
# build time now — per-request state (the signed assertion, the
|
|
19
|
+
# `Idempotency-Key`, the query params) rides on {Proxy} instead.
|
|
20
|
+
#
|
|
21
|
+
# ## Why not a connection per thread
|
|
22
|
+
#
|
|
23
|
+
# A thread-local connection is the obvious cheap version and it is wrong for
|
|
24
|
+
# this consumer: Cerberus fans out its page reads on short-lived threads, so
|
|
25
|
+
# a thread-local pool dies with the thread that built it and the fan-out —
|
|
26
|
+
# the calls that most want reuse — reuses nothing. `net-http-persistent`
|
|
27
|
+
# keeps a shared `ConnectionPool` and uses `Thread.current` only to track
|
|
28
|
+
# re-entrant checkouts, so a socket checked in by a dying thread goes back to
|
|
29
|
+
# the shared stack.
|
|
30
|
+
#
|
|
31
|
+
# ## Thread safety
|
|
32
|
+
#
|
|
33
|
+
# A Faraday connection is safe for concurrent requests as long as nothing
|
|
34
|
+
# mutates it after it is built. Everything here respects that: the cached
|
|
35
|
+
# connection is built once under {MUTEX} (with its middleware stack forced
|
|
36
|
+
# so two threads cannot race to build two adapters, and therefore two pools),
|
|
37
|
+
# and per-request headers and params are set on the request, never assigned
|
|
38
|
+
# onto the shared connection.
|
|
39
|
+
module Transport
|
|
40
|
+
# Sockets kept per host. Sized for the consumer's shape rather than left at
|
|
41
|
+
# the `net-http-persistent` default (256, or a quarter of the open-file
|
|
42
|
+
# limit): Cerberus draws up to four concurrent reads per Puma thread, so
|
|
43
|
+
# the useful number is that fan-out times the thread count, plus headroom.
|
|
44
|
+
DEFAULT_POOL_SIZE = 16
|
|
45
|
+
|
|
46
|
+
MUTEX = Mutex.new
|
|
47
|
+
# Separate from MUTEX so the key cache can never deadlock against a
|
|
48
|
+
# connection build that needs a signed header.
|
|
49
|
+
KEY_MUTEX = Mutex.new
|
|
50
|
+
private_constant :MUTEX, :KEY_MUTEX
|
|
51
|
+
|
|
52
|
+
# Per-request view onto a shared, cached Faraday connection.
|
|
53
|
+
#
|
|
54
|
+
# It exists because the state a request needs — the signed assertion (30s
|
|
55
|
+
# TTL), the `Idempotency-Key`, the query params — used to be baked into the
|
|
56
|
+
# connection at build time, and a connection that outlives the request
|
|
57
|
+
# cannot carry any of it. The proxy holds that state and applies it to each
|
|
58
|
+
# request instead.
|
|
59
|
+
#
|
|
60
|
+
# The caller-supplied block is forwarded, not swallowed: call sites that
|
|
61
|
+
# need per-request control already use Faraday's block form (a `Range`
|
|
62
|
+
# header, an `on_data` streaming handler), and those must keep working.
|
|
63
|
+
# The block runs last so a call site can override anything set here.
|
|
64
|
+
class Proxy
|
|
65
|
+
# Per-request headers and params, exposed so a caller (and the gem's own
|
|
66
|
+
# transport specs) can read what a request will send without making one.
|
|
67
|
+
attr_reader :headers, :params
|
|
68
|
+
|
|
69
|
+
# The shared connection behind this proxy, for reading its middleware
|
|
70
|
+
# stack or adapter. Do not mutate it — it is shared across every request
|
|
71
|
+
# of this shape, including ones in flight on other threads.
|
|
72
|
+
attr_reader :connection
|
|
73
|
+
|
|
74
|
+
# @param connection [Faraday::Connection] the shared, cached connection.
|
|
75
|
+
# @param headers [Hash] per-request headers (auth, content type,
|
|
76
|
+
# idempotency key).
|
|
77
|
+
# @param params [Hash] per-request query/body params.
|
|
78
|
+
def initialize(connection, headers, params = {})
|
|
79
|
+
@connection = connection
|
|
80
|
+
@headers = headers
|
|
81
|
+
@params = params || {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @!method get(path, params = nil, headers = nil, &block)
|
|
85
|
+
# @!method delete(path, params = nil, headers = nil, &block)
|
|
86
|
+
# Bodyless verbs — a second positional argument is query params, matching
|
|
87
|
+
# `Faraday::Connection`.
|
|
88
|
+
%i[get delete].each do |verb|
|
|
89
|
+
define_method(verb) do |path, params = nil, headers = nil, &block|
|
|
90
|
+
run(verb, path, nil, headers, params, &block)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @!method post(path, body = nil, headers = nil, &block)
|
|
95
|
+
# @!method patch(path, body = nil, headers = nil, &block)
|
|
96
|
+
# @!method put(path, body = nil, headers = nil, &block)
|
|
97
|
+
# Body-carrying verbs — a second positional argument is the body,
|
|
98
|
+
# matching `Faraday::Connection`.
|
|
99
|
+
%i[post patch put].each do |verb|
|
|
100
|
+
define_method(verb) do |path, body = nil, headers = nil, &block|
|
|
101
|
+
run(verb, path, body, headers, nil, &block)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Escape hatch for a verb the five above don't cover, with the same
|
|
106
|
+
# per-request header/param application.
|
|
107
|
+
#
|
|
108
|
+
# @return [Faraday::Response]
|
|
109
|
+
def run_request(method, path, body, headers, &block)
|
|
110
|
+
run(method, path, body, headers, nil, &block)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def run(method, path, body, headers, params, &block)
|
|
116
|
+
@connection.run_request(method, path, body, headers) do |req|
|
|
117
|
+
req.params.update(@params) unless @params.empty?
|
|
118
|
+
req.params.update(params) if params
|
|
119
|
+
req.headers.update(@headers)
|
|
120
|
+
block&.call(req)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class << self
|
|
126
|
+
# Fetch the cached connection for `key`, building it from the block on
|
|
127
|
+
# first use.
|
|
128
|
+
#
|
|
129
|
+
# @param key [Array] connection shape and base URL.
|
|
130
|
+
# @yieldreturn [Faraday::Connection] a freshly built connection.
|
|
131
|
+
# @return [Faraday::Connection] the shared connection for `key`.
|
|
132
|
+
def connection_for(key)
|
|
133
|
+
MUTEX.synchronize do
|
|
134
|
+
connections[key] ||= begin
|
|
135
|
+
conn = yield
|
|
136
|
+
# Force the middleware stack (and with it the adapter, and with it
|
|
137
|
+
# the pool) while still holding the lock. Faraday memoizes the app
|
|
138
|
+
# lazily on first request, and two threads racing that memoization
|
|
139
|
+
# would each build an adapter — two pools, no reuse.
|
|
140
|
+
conn.builder.app
|
|
141
|
+
conn
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Apply the pool settings a host has configured to a
|
|
147
|
+
# `Net::HTTP::Persistent`. Called by the adapter's config block on every
|
|
148
|
+
# request, so it must stay assignment-only and cheap.
|
|
149
|
+
#
|
|
150
|
+
# `max_retries` is restored to 1 here because the adapter zeroes it, and
|
|
151
|
+
# zero is the wrong default for a pooled socket: a server that closed an
|
|
152
|
+
# idle connection produces an error on the next write, and one retry is
|
|
153
|
+
# what turns that into a reconnect instead of a caller-visible failure.
|
|
154
|
+
# `Net::HTTP` gates its retry on `IDEMPOTENT_METHODS_`, so the retry-safe
|
|
155
|
+
# creates (`POST /works`, `/file_sets`, `/files`) are never replayed.
|
|
156
|
+
#
|
|
157
|
+
# @param http [Net::HTTP::Persistent]
|
|
158
|
+
# @return [void]
|
|
159
|
+
def configure_persistent(http)
|
|
160
|
+
http.max_retries = 1
|
|
161
|
+
http.max_requests = AtlasRb.config.connection_max_requests
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Pool size for a newly built adapter.
|
|
165
|
+
#
|
|
166
|
+
# @return [Integer]
|
|
167
|
+
def pool_size
|
|
168
|
+
AtlasRb.config.connection_pool_size || DEFAULT_POOL_SIZE
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Parse a PEM into an `OpenSSL::PKey`, reusing the last parse.
|
|
172
|
+
#
|
|
173
|
+
# Parsing the key costs roughly five times the ES256 signature it exists
|
|
174
|
+
# to produce, and the signing key is configured as a callable returning a
|
|
175
|
+
# PEM, so it was being reparsed on every request. One entry is enough —
|
|
176
|
+
# the cache is a rotation check, not a store, so a rotated key parses
|
|
177
|
+
# once and the old one is dropped rather than accumulating.
|
|
178
|
+
#
|
|
179
|
+
# @param pem [String] the PEM-encoded key.
|
|
180
|
+
# @return [OpenSSL::PKey::PKey]
|
|
181
|
+
def parsed_key(pem)
|
|
182
|
+
KEY_MUTEX.synchronize do
|
|
183
|
+
if @parsed_pem != pem
|
|
184
|
+
@parsed_pem = pem
|
|
185
|
+
@parsed_key = OpenSSL::PKey.read(pem)
|
|
186
|
+
end
|
|
187
|
+
@parsed_key
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Close every pooled socket and drop the cached connections.
|
|
192
|
+
#
|
|
193
|
+
# Pooled sockets outliving a test example are a new source of
|
|
194
|
+
# cross-example coupling, so a suite that boots and tears down a real
|
|
195
|
+
# server (Atlas's `:atlas_rb_server` layer) should call this in its
|
|
196
|
+
# teardown. Shutting a `net-http-persistent` pool down makes it refuse
|
|
197
|
+
# later checkouts, which is why the cached connections are dropped in the
|
|
198
|
+
# same breath — the next call rebuilds both.
|
|
199
|
+
#
|
|
200
|
+
# @return [void]
|
|
201
|
+
def reset_connections!
|
|
202
|
+
MUTEX.synchronize do
|
|
203
|
+
connections.each_value do |conn|
|
|
204
|
+
conn.close
|
|
205
|
+
rescue StandardError
|
|
206
|
+
# A pool already shut down, or a socket already gone, has nothing
|
|
207
|
+
# left to release. The connection is being discarded either way.
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
connections.clear
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def connections
|
|
217
|
+
@connections ||= {}
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
data/lib/atlas_rb.rb
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "faraday/multipart"
|
|
5
5
|
require "faraday/follow_redirects"
|
|
6
|
+
# Pooling adapter. Required here, not lazily, because the transport pins it on
|
|
7
|
+
# every connection — see AtlasRb::FaradayHelper#persistent_adapter.
|
|
8
|
+
require "faraday/net_http_persistent"
|
|
6
9
|
require "jwt"
|
|
7
10
|
require "openssl"
|
|
8
11
|
require "securerandom"
|
|
9
12
|
require_relative "atlas_rb/version"
|
|
10
13
|
require_relative "atlas_rb/errors"
|
|
11
14
|
require_relative "atlas_rb/configuration"
|
|
15
|
+
require_relative "atlas_rb/transport"
|
|
12
16
|
require_relative "atlas_rb/middleware/raise_on_stale_resource"
|
|
13
17
|
require_relative "atlas_rb/middleware/raise_on_resource_error"
|
|
14
18
|
require_relative "atlas_rb/middleware/raise_on_read_only_mode"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: atlas_rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Cliff
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -66,6 +66,20 @@ dependencies:
|
|
|
66
66
|
- - "~>"
|
|
67
67
|
- !ruby/object:Gem::Version
|
|
68
68
|
version: 0.3.0
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: faraday-net_http_persistent
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '2.3'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '2.3'
|
|
69
83
|
- !ruby/object:Gem::Dependency
|
|
70
84
|
name: hashie
|
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -155,6 +169,7 @@ files:
|
|
|
155
169
|
- lib/atlas_rb/system/token.rb
|
|
156
170
|
- lib/atlas_rb/system/user.rb
|
|
157
171
|
- lib/atlas_rb/system/work.rb
|
|
172
|
+
- lib/atlas_rb/transport.rb
|
|
158
173
|
- lib/atlas_rb/user.rb
|
|
159
174
|
- lib/atlas_rb/version.rb
|
|
160
175
|
- lib/atlas_rb/work.rb
|