traject-solr_pool 0.1.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 +7 -0
- data/.github/workflows/ci.yml +34 -0
- data/CLAUDE.md +237 -0
- data/LICENSE.txt +21 -0
- data/README.md +167 -0
- data/Rakefile +21 -0
- data/docs/agents/README.md +41 -0
- data/docs/skills/README.md +42 -0
- data/docs/superpowers/plans/2026-07-01-traject-solr-pool-writer.md +1387 -0
- data/docs/superpowers/specs/2026-07-01-traject-solr-pool-writer-design.md +265 -0
- data/docs/usage.md +122 -0
- data/lib/traject/solr_pool/connection.rb +80 -0
- data/lib/traject/solr_pool/solr_json_writer.rb +303 -0
- data/lib/traject/solr_pool/version.rb +7 -0
- data/lib/traject/solr_pool.rb +15 -0
- data/sig/traject/solr_pool.rbs +6 -0
- metadata +95 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Design: traject-solr_pool pooled Solr JSON writer
|
|
2
|
+
|
|
3
|
+
- Date: 2026-07-01
|
|
4
|
+
- Status: Approved (design phase)
|
|
5
|
+
- Scope: A traject writer that routes Solr updates through a persistent,
|
|
6
|
+
pooled, thread-safe HTTP client provided by `http_connection_pool`, with a
|
|
7
|
+
reusable connection seam for a future reader.
|
|
8
|
+
|
|
9
|
+
## Goal
|
|
10
|
+
|
|
11
|
+
Provide `Traject::SolrPool::SolrJsonWriter`, a drop-in replacement for
|
|
12
|
+
`Traject::SolrJsonWriter` that sends every Solr HTTP call over a persistent,
|
|
13
|
+
credential-isolated connection pool (from the sibling `http_connection_pool`
|
|
14
|
+
gem) instead of opening a fresh `HTTPClient` connection per request.
|
|
15
|
+
|
|
16
|
+
The pooling logic is **not** reimplemented here. This gem is the traject-facing
|
|
17
|
+
adapter. It must:
|
|
18
|
+
|
|
19
|
+
- register via traject's `writer_class_name` setting,
|
|
20
|
+
- honour the existing `solr.*` / `solr_writer.*` settings surface so it is a
|
|
21
|
+
drop-in wherever practical,
|
|
22
|
+
- route every HTTP call through an `http_connection_pool` pool keyed by the
|
|
23
|
+
Solr origin,
|
|
24
|
+
- be thread-safe and usable from Sidekiq >= 8 workers, and coexist cleanly with
|
|
25
|
+
Zeitwerk and Rails (verified by tests; no Rails runtime dependency).
|
|
26
|
+
|
|
27
|
+
A reader is **future scope**: the connection unit is designed so a reader can
|
|
28
|
+
reuse it later, but no reader is built now.
|
|
29
|
+
|
|
30
|
+
## Non-goals
|
|
31
|
+
|
|
32
|
+
- Reimplementing connection pooling, keep-alive, or socket handling.
|
|
33
|
+
- A new settings vocabulary. Reuse `solr.*` / `solr_writer.*`; new settings are
|
|
34
|
+
namespaced under `solr_pool.*` and documented.
|
|
35
|
+
- Building the reader.
|
|
36
|
+
- JRuby support (not verified).
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
Two units, split along the boundary the future reader will reuse. The reader
|
|
41
|
+
shares the **connection**, not the batching/threading — so the reusable seam is
|
|
42
|
+
drawn around the connection.
|
|
43
|
+
|
|
44
|
+
### Unit A — `Traject::SolrPool::Connection` (reusable)
|
|
45
|
+
|
|
46
|
+
Owns everything `http_connection_pool`-related. Constructed once from
|
|
47
|
+
`settings` inside the writer's `initialize`.
|
|
48
|
+
|
|
49
|
+
Responsibilities:
|
|
50
|
+
|
|
51
|
+
- Derive the **origin** (`scheme://host:port`) from the resolved Solr update
|
|
52
|
+
URL. The origin becomes the pool `base_url`; the remainder of the URL becomes
|
|
53
|
+
the relative request path used per request.
|
|
54
|
+
- Build `pool_options` from settings: a JSON content-type header, a Basic-auth
|
|
55
|
+
header when credentials are present, and timeout options. Credentials live in
|
|
56
|
+
headers (never baked into the origin), so distinct credentials resolve to
|
|
57
|
+
distinct pool digests and never share connections.
|
|
58
|
+
- Bind to the pool using `http_connection_pool`'s documented `extend` path:
|
|
59
|
+
create a small adapter object and `adapter.extend(HttpConnectionPool::Connectable)`,
|
|
60
|
+
then set `base_url`, `pool_size`, `pool_timeout`, and `pool_options` from
|
|
61
|
+
settings. Pool sharing and credential isolation are enforced by the
|
|
62
|
+
`Registry` digest, not by class-level state, so per-instance runtime config is
|
|
63
|
+
correct: same origin + same auth share one pool; different auth get separate
|
|
64
|
+
pools.
|
|
65
|
+
|
|
66
|
+
Public API:
|
|
67
|
+
|
|
68
|
+
- `post_json(path, json_string)` — `with_connection { |conn| conn.post(path, body: json_string) }`, returns the http.rb response.
|
|
69
|
+
- `get(path, params:)` — `with_connection { |conn| conn.get(path, params: params) }`, returns the http.rb response.
|
|
70
|
+
- Readers exposing the derived origin and the relative update path base so the
|
|
71
|
+
writer can build request paths and query strings.
|
|
72
|
+
|
|
73
|
+
Transport errors are surfaced at this boundary for the writer to classify (see
|
|
74
|
+
Error handling).
|
|
75
|
+
|
|
76
|
+
### Unit B — `Traject::SolrPool::SolrJsonWriter` (the writer)
|
|
77
|
+
|
|
78
|
+
A fresh port of the stock writer's **structure**, owning all traject-specific
|
|
79
|
+
concerns and delegating **all** HTTP to Unit A. Registered via
|
|
80
|
+
`writer_class_name: 'Traject::SolrPool::SolrJsonWriter'`.
|
|
81
|
+
|
|
82
|
+
Ported from the stock writer (structure reused, not subclassed — subclassing
|
|
83
|
+
would couple us to the parent's private methods and `HTTPClient`-specific
|
|
84
|
+
exception types):
|
|
85
|
+
|
|
86
|
+
- batched `Queue` and `Traject::ThreadPool`,
|
|
87
|
+
- `Concurrent::AtomicFixnum` skipped-record counter,
|
|
88
|
+
- `put`, `flush`, `send_batch`, `send_single`, `close`,
|
|
89
|
+
- `commit`, `delete`, `delete_all!`, `skipped_record_count`,
|
|
90
|
+
- settings/URL interpretation and credential stripping from logs.
|
|
91
|
+
|
|
92
|
+
HTTP calls (`@http_client.post/get`) are replaced with `connection.post_json` /
|
|
93
|
+
`connection.get`.
|
|
94
|
+
|
|
95
|
+
### Data flow
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
put(context)
|
|
99
|
+
-> batched_queue << context
|
|
100
|
+
-> when queue >= batch_size: thread_pool runs send_batch(batch)
|
|
101
|
+
-> JSON.generate(batch docs)
|
|
102
|
+
-> connection.post_json(update_path_with_query, json)
|
|
103
|
+
-> Registry pool: borrow HTTP::Session, POST, return session
|
|
104
|
+
-> on failure: retry each doc via send_single (per-record skip logic)
|
|
105
|
+
close
|
|
106
|
+
-> flush remaining batch (via thread pool)
|
|
107
|
+
-> shutdown_and_wait on thread pool
|
|
108
|
+
-> commit if commit_on_close
|
|
109
|
+
-> LEAVE POOL WARM (no Registry.release)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Settings
|
|
113
|
+
|
|
114
|
+
Existing vocabulary, reused unchanged (drop-in):
|
|
115
|
+
|
|
116
|
+
| Setting | Role | Notes |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `solr.url` / `solr.update_url` | Origin + update path | Same derivation as stock: derive `/update/json` from `solr.url` when `update_url` unset. Origin becomes pool `base_url`; remainder becomes the relative request path. |
|
|
119
|
+
| `solr_writer.batch_size` | Batch size | Default 100 |
|
|
120
|
+
| `solr_writer.thread_pool` | Writer threads | Default 1 |
|
|
121
|
+
| `solr_writer.max_skipped` | Skip tolerance | Same semantics |
|
|
122
|
+
| `solr_writer.skippable_exceptions` | Custom skip list | Honoured if set; default list is http.rb-native (see Error handling) |
|
|
123
|
+
| `solr_writer.commit_on_close` | Commit at close | Same (incl. legacy `solrj_writer.commit_on_close`) |
|
|
124
|
+
| `solr_writer.solr_update_args` | Update query params | Same |
|
|
125
|
+
| `solr_writer.commit_solr_update_args` | Commit query params | Same |
|
|
126
|
+
| `solr_writer.http_timeout` | Request timeout | Fed into `pool_options` / applied per request via the http.rb chainable |
|
|
127
|
+
| `solr_writer.commit_timeout` | Commit timeout | Same intent, applied to the commit request |
|
|
128
|
+
| `solr_writer.basic_auth_user` / `solr_writer.basic_auth_password` | Auth | Same precedence; also parsed from embedded URI creds and stripped before logging |
|
|
129
|
+
|
|
130
|
+
New, namespaced:
|
|
131
|
+
|
|
132
|
+
| Setting | Default | Role |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| `solr_pool.pool_size` | `solr_writer.thread_pool` + caller-thread headroom | Pool capacity, sized so no writer thread starves on checkout while the caller thread can still flush/commit |
|
|
135
|
+
|
|
136
|
+
Deliberately dropped (HTTPClient-specific; documented as removed/no-op):
|
|
137
|
+
|
|
138
|
+
- `solr_json_writer.http_client` — replaced by the pool; tests inject behaviour
|
|
139
|
+
via WebMock instead of a client object.
|
|
140
|
+
- `solr_json_writer.use_packaged_certs` — an `HTTPClient` ssl_config quirk;
|
|
141
|
+
http.rb uses OS certs.
|
|
142
|
+
|
|
143
|
+
## Error handling
|
|
144
|
+
|
|
145
|
+
- **`BadHttpResponse` preserved** for parity:
|
|
146
|
+
`Traject::SolrPool::SolrJsonWriter::BadHttpResponse < RuntimeError`, raised on
|
|
147
|
+
any non-200. Keeps its `#response` accessor and JSON `error.msg` extraction,
|
|
148
|
+
but reads from an http.rb response (`response.code`, `response.to_s`). The
|
|
149
|
+
`#response` object is now an http.rb response; documented as such.
|
|
150
|
+
- **Default `skippable_exceptions` becomes http.rb-native**, mapping transport
|
|
151
|
+
failures so `max_skipped` logic still works:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
[
|
|
155
|
+
HTTP::TimeoutError, # http.rb read/connect timeout
|
|
156
|
+
HttpConnectionPool::TimeoutError, # pool checkout exhausted within pool_timeout
|
|
157
|
+
HTTP::ConnectionError, # http.rb transport failure (refused/reset)
|
|
158
|
+
SocketError,
|
|
159
|
+
Errno::ECONNREFUSED,
|
|
160
|
+
Traject::SolrPool::SolrJsonWriter::BadHttpResponse
|
|
161
|
+
]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Exact http.rb error constant names are confirmed against the installed gem
|
|
165
|
+
during implementation (TDD catches a wrong constant immediately); the mapping
|
|
166
|
+
intent above is authoritative.
|
|
167
|
+
- A user who sets `solr_writer.skippable_exceptions` explicitly gets exactly
|
|
168
|
+
their list (unchanged behaviour).
|
|
169
|
+
- Pool checkout timeout (`HttpConnectionPool::TimeoutError`) is skippable: under
|
|
170
|
+
a saturated pool a batch is skipped / retried individually rather than
|
|
171
|
+
aborting the run, matching how timeouts behave today.
|
|
172
|
+
- `commit` and `delete` raise raw on failure (as the stock writer does — not
|
|
173
|
+
part of the skip path).
|
|
174
|
+
- Credentials never appear in any log or error message: auth lives in headers,
|
|
175
|
+
logs show origin only.
|
|
176
|
+
|
|
177
|
+
## Thread safety, Sidekiq, Zeitwerk, Rails
|
|
178
|
+
|
|
179
|
+
- **Thread safety** is mandatory. Shared state uses `concurrent-ruby`
|
|
180
|
+
primitives (`Concurrent::AtomicFixnum` counter, `Queue`) as the stock writer
|
|
181
|
+
does; no coarse global `Mutex` on the hot path. Connection checkout/return is
|
|
182
|
+
owned entirely by `http_connection_pool`; a borrowed connection is never held
|
|
183
|
+
across threads.
|
|
184
|
+
- **Sidekiq >= 8**: the writer must work inside a worker — instances created and
|
|
185
|
+
torn down per job, many threads concurrent, process forks. Fork-safety is
|
|
186
|
+
inherited from `connection_pool >= 2.5` (`auto_reload_after_fork`); this gem
|
|
187
|
+
caches no raw sockets or connection objects across a fork.
|
|
188
|
+
- **Zeitwerk / Rails**: file/constant layout stays Zeitwerk-conformant so a host
|
|
189
|
+
app's loader is happy, even though traject loads via plain `require`. No Rails
|
|
190
|
+
runtime dependency — Rails/Zeitwerk/Sidekiq are test-only.
|
|
191
|
+
|
|
192
|
+
## Testing strategy (TDD, WebMock)
|
|
193
|
+
|
|
194
|
+
Unit specs (fast, no live Solr) drive **real http.rb through the real pool**,
|
|
195
|
+
with **WebMock** intercepting responses:
|
|
196
|
+
|
|
197
|
+
- `Connection`: origin derivation, `pool_options` / auth-header construction,
|
|
198
|
+
credential isolation (different auth => different pool digest), relative-path
|
|
199
|
+
requests resolve against the origin.
|
|
200
|
+
- `SolrJsonWriter`: `put` batching at `batch_size`; `send_batch` posts one JSON
|
|
201
|
+
array; batch failure falls back to per-record `send_single`;
|
|
202
|
+
`max_skipped` / skip-counter behaviour; `commit` / `delete` / `delete_all!`;
|
|
203
|
+
URL derivation; credential stripping from logs; the `solr_pool.pool_size`
|
|
204
|
+
default.
|
|
205
|
+
- Error mapping: WebMock simulates timeouts / 500s / connection-refused; assert
|
|
206
|
+
skippable behaviour and `BadHttpResponse` with its `error.msg`.
|
|
207
|
+
|
|
208
|
+
Integration specs (tagged, slower):
|
|
209
|
+
|
|
210
|
+
- **Concurrency**: `solr_writer.thread_pool > 1` sending many batches through the
|
|
211
|
+
pool; assert no lost/corrupted records and no checkout starvation. Barrier
|
|
212
|
+
helper lives in a `spec/support` module included by tag.
|
|
213
|
+
- **Sidekiq >= 8 / fork**: writer used from a job-like context across a fork;
|
|
214
|
+
assert fork-safety (fresh connections, no shared sockets).
|
|
215
|
+
- **Zeitwerk / Rails**: clean-subprocess eager-load proving Zeitwerk
|
|
216
|
+
conformance; a Rails-style service-object coexistence check.
|
|
217
|
+
|
|
218
|
+
Conventions (from `CLAUDE.md`): single-quoted non-interpolated strings; no
|
|
219
|
+
apostrophes in example descriptions; `after do` teardown; reset
|
|
220
|
+
`HttpConnectionPool::Registry` between examples so pools never leak;
|
|
221
|
+
`spec/support/**` auto-required.
|
|
222
|
+
|
|
223
|
+
## Dependencies
|
|
224
|
+
|
|
225
|
+
Runtime (gemspec):
|
|
226
|
+
|
|
227
|
+
- `http_connection_pool` `~> 0.1`
|
|
228
|
+
- `traject` — currently via a **temporary** Gemfile `path:` override to an edge
|
|
229
|
+
checkout (the last released traject caps `http < 6`, colliding with
|
|
230
|
+
`http_connection_pool`'s `http ~> 6.0`; edge relaxes to `http >= 3.0, < 7`).
|
|
231
|
+
The gemspec constraint is switched to the real released version once traject
|
|
232
|
+
ships the relaxed cap; then the `path:` override is removed.
|
|
233
|
+
|
|
234
|
+
Test-only (a `:test` Gemfile group, nothing in `:development`):
|
|
235
|
+
|
|
236
|
+
- `webmock` — HTTP interception
|
|
237
|
+
- `activesupport` — Rails-coexistence checks (specific module, not full `rails`)
|
|
238
|
+
- `activejob` — Sidekiq-style job harness
|
|
239
|
+
- `zeitwerk` — standalone loader for the eager-load compliance spec (separate
|
|
240
|
+
gem from activesupport; required directly)
|
|
241
|
+
- `sidekiq` — exercise the writer inside a real Sidekiq >= 8 worker context
|
|
242
|
+
|
|
243
|
+
`rails` is intentionally not pulled in; `activesupport` + `activejob` cover the
|
|
244
|
+
coexistence and job-integration cases. If a spec later genuinely needs Action
|
|
245
|
+
Pack or Railties, that is flagged rather than added silently.
|
|
246
|
+
|
|
247
|
+
## File / constant layout
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
lib/
|
|
251
|
+
traject/solr_pool.rb # entry point (requires the pieces)
|
|
252
|
+
traject/solr_pool/
|
|
253
|
+
version.rb # Traject::SolrPool::VERSION
|
|
254
|
+
connection.rb # Traject::SolrPool::Connection
|
|
255
|
+
solr_json_writer.rb # Traject::SolrPool::SolrJsonWriter
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Layout stays Zeitwerk-conformant (file path matches constant name).
|
|
259
|
+
|
|
260
|
+
## Open items deferred to the plan
|
|
261
|
+
|
|
262
|
+
- Exact http.rb error constant names, verified against the installed gem.
|
|
263
|
+
- Precise `pool_size` headroom value (thread_pool + 1 vs a small constant).
|
|
264
|
+
- Whether `commit_timeout` maps to a per-request http.rb read-timeout override
|
|
265
|
+
or a distinct pool option.
|
data/docs/usage.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Usage reference
|
|
2
|
+
|
|
3
|
+
This is the fuller settings and behaviour reference for
|
|
4
|
+
`Traject::SolrPool::SolrJsonWriter`. See the project README for a quick start.
|
|
5
|
+
|
|
6
|
+
## Registering the writer
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
require 'traject/solr_pool'
|
|
10
|
+
|
|
11
|
+
settings do
|
|
12
|
+
provide 'writer_class_name', 'Traject::SolrPool::SolrJsonWriter'
|
|
13
|
+
provide 'solr.url', 'http://localhost:8983/solr/my_core'
|
|
14
|
+
provide 'solr_writer.thread_pool', 4
|
|
15
|
+
provide 'solr_pool.pool_size', 5
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The writer derives the origin (`scheme://host:port`) from the resolved update
|
|
20
|
+
URL and uses it as the connection pool's `base_url`. The remainder of the URL
|
|
21
|
+
becomes the relative request path sent on each call, so requests resolve
|
|
22
|
+
against the persistent origin rather than re-encoding the absolute URL.
|
|
23
|
+
|
|
24
|
+
## Settings
|
|
25
|
+
|
|
26
|
+
The writer reuses the stock `solr.*` / `solr_writer.*` vocabulary. Only
|
|
27
|
+
`solr_pool.pool_size` is new.
|
|
28
|
+
|
|
29
|
+
| Setting | Default | Role |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `solr.url` | (required unless `solr.update_url` set) | Base Solr URL; the `/update/json` handler is derived from it |
|
|
32
|
+
| `solr.update_url` | derived from `solr.url` | Full update handler URL; used verbatim when provided |
|
|
33
|
+
| `solr_writer.batch_size` | `100` | Documents accumulated before a batched update is sent (values below 1 are clamped to 1) |
|
|
34
|
+
| `solr_writer.thread_pool` | `1` | Number of background writer threads |
|
|
35
|
+
| `solr_writer.max_skipped` | `0` | Skip tolerance before `MaxSkippedRecordsExceeded` is raised; a negative value disables the cap |
|
|
36
|
+
| `solr_writer.skippable_exceptions` | http.rb-native list (see error handling) | Exceptions treated as skippable during per-record retry |
|
|
37
|
+
| `solr_writer.commit_on_close` | `false` | Send a commit when the writer closes; the legacy `solrj_writer.commit_on_close` key is also honoured |
|
|
38
|
+
| `solr_writer.solr_update_args` | none | Query params (e.g. `{ 'commitWithin' => 1000 }`) applied to every update and delete request |
|
|
39
|
+
| `solr_writer.commit_solr_update_args` | `{ 'commit' => 'true' }` | Query params appended to the commit request |
|
|
40
|
+
| `solr_writer.commit_timeout` | `600` (10 min) | Per-request read timeout applied to the commit request only, so a slow hard commit is not cut off by a short `http_timeout` |
|
|
41
|
+
| `solr_writer.basic_auth_user` | embedded URI user | Basic-auth user; overrides any credentials embedded in the URL |
|
|
42
|
+
| `solr_writer.basic_auth_password` | embedded URI password | Basic-auth password; overrides any credentials embedded in the URL |
|
|
43
|
+
| `solr_writer.http_timeout` | none | Per-request HTTP timeout, passed through to the pooled connection |
|
|
44
|
+
| `solr_writer.pool_timeout` | pool default | Maximum time to wait for a connection checkout from the pool |
|
|
45
|
+
| `solr_pool.pool_size` | `solr_writer.thread_pool` + 1 | Pool capacity, sized so no writer thread starves on checkout while the caller thread can still flush and commit |
|
|
46
|
+
|
|
47
|
+
### Credential handling
|
|
48
|
+
|
|
49
|
+
Basic-auth credentials may be provided explicitly through
|
|
50
|
+
`solr_writer.basic_auth_user` / `solr_writer.basic_auth_password`, or embedded
|
|
51
|
+
in the URL (`http://user:pass@host/...`). Explicit settings take precedence.
|
|
52
|
+
Embedded credentials are stripped from the stored update URL and origin, so
|
|
53
|
+
they never appear in logs. Auth is sent as an `Authorization` header, which
|
|
54
|
+
means different credentials for the same host resolve to different pools and
|
|
55
|
+
never share connections.
|
|
56
|
+
|
|
57
|
+
### Dropped settings
|
|
58
|
+
|
|
59
|
+
Two `solr_json_writer.*` settings from the stock writer are HTTPClient-specific
|
|
60
|
+
and are intentionally unsupported:
|
|
61
|
+
|
|
62
|
+
- `solr_json_writer.http_client` — connection construction is owned by the
|
|
63
|
+
pool, so there is no `HTTPClient` instance to inject.
|
|
64
|
+
- `solr_json_writer.use_packaged_certs` — an `HTTPClient` ssl_config quirk;
|
|
65
|
+
http.rb uses the operating system certificate store.
|
|
66
|
+
|
|
67
|
+
## Error handling
|
|
68
|
+
|
|
69
|
+
On any non-200 response the writer raises
|
|
70
|
+
`Traject::SolrPool::SolrJsonWriter::BadHttpResponse`, a subclass of
|
|
71
|
+
`RuntimeError`. Its `#response` accessor returns the raw http.rb response, so
|
|
72
|
+
callers use `#code` and `#to_s` (not HTTPClient message methods). When Solr
|
|
73
|
+
returns a JSON body, the `error.msg` field is extracted into the exception
|
|
74
|
+
message.
|
|
75
|
+
|
|
76
|
+
The default `solr_writer.skippable_exceptions` list is http.rb-native:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
[
|
|
80
|
+
HTTP::TimeoutError, # http.rb read/connect timeout
|
|
81
|
+
HttpConnectionPool::TimeoutError, # pool checkout exhausted within pool_timeout
|
|
82
|
+
HTTP::ConnectionError, # http.rb transport failure (refused/reset)
|
|
83
|
+
SocketError,
|
|
84
|
+
Errno::ECONNREFUSED,
|
|
85
|
+
Traject::SolrPool::SolrJsonWriter::BadHttpResponse
|
|
86
|
+
]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Behaviour:
|
|
90
|
+
|
|
91
|
+
- A batch that fails to POST cleanly is retried one record at a time via the
|
|
92
|
+
per-record path.
|
|
93
|
+
- A record raising a skippable exception is logged, and the skipped-record
|
|
94
|
+
counter is incremented. The run aborts with `MaxSkippedRecordsExceeded` only
|
|
95
|
+
once the count passes `solr_writer.max_skipped`.
|
|
96
|
+
- Setting `solr_writer.skippable_exceptions` explicitly replaces the default
|
|
97
|
+
list with exactly your own.
|
|
98
|
+
- `commit`, `delete`, and `delete_all!` raise raw on failure; they are not part
|
|
99
|
+
of the skip path.
|
|
100
|
+
- Credentials never appear in any log or error message.
|
|
101
|
+
|
|
102
|
+
## Pool lifecycle
|
|
103
|
+
|
|
104
|
+
`close` performs the writer's normal shutdown: it flushes any queued records,
|
|
105
|
+
runs the final batch through the thread pool, waits for the background threads
|
|
106
|
+
to finish, and issues a commit when `solr_writer.commit_on_close` is set.
|
|
107
|
+
|
|
108
|
+
`close` then **leaves the pool warm** in the `http_connection_pool` registry.
|
|
109
|
+
It does not release or tear down the pool. This lets a later writer (or a
|
|
110
|
+
future reader) on the same origin reuse the existing persistent connections
|
|
111
|
+
rather than paying to rebuild them.
|
|
112
|
+
|
|
113
|
+
Pool teardown is the host application's responsibility. Close pools explicitly
|
|
114
|
+
on process shutdown, or after a fork, using the registry:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
HttpConnectionPool::Registry.instance.close_all
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Fork safety itself is inherited from `connection_pool >= 2.5`
|
|
121
|
+
(`auto_reload_after_fork`): this gem caches no raw sockets or connection
|
|
122
|
+
objects across a fork, so Sidekiq workers get fresh connections automatically.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'http_connection_pool'
|
|
4
|
+
|
|
5
|
+
module Traject
|
|
6
|
+
module SolrPool
|
|
7
|
+
# Reusable persistent-connection seam over http_connection_pool. Owns origin
|
|
8
|
+
# binding and pool_options construction; exposes post/get that borrow a
|
|
9
|
+
# pooled HTTP::Session. A future reader can reuse this unchanged.
|
|
10
|
+
class Connection
|
|
11
|
+
# Keyword bundle so initialize stays under the 5-param limit.
|
|
12
|
+
Options = Struct.new(:headers, :auth, :timeout, keyword_init: true) do
|
|
13
|
+
def to_pool_opts
|
|
14
|
+
opts = { headers: headers }
|
|
15
|
+
opts[:auth] = auth if auth
|
|
16
|
+
opts[:timeout] = timeout if timeout
|
|
17
|
+
opts
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :origin
|
|
22
|
+
|
|
23
|
+
def initialize(origin:, pool_size:, pool_timeout: nil, **opts)
|
|
24
|
+
@origin = origin
|
|
25
|
+
@pool_size = pool_size
|
|
26
|
+
@pool_timeout = pool_timeout
|
|
27
|
+
@pool_options = Options.new(headers: opts.fetch(:headers, {}),
|
|
28
|
+
auth: opts[:auth],
|
|
29
|
+
timeout: opts[:timeout]).to_pool_opts
|
|
30
|
+
@adapter = build_adapter
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def post(path, body:)
|
|
34
|
+
@adapter.with_connection { |conn| conn.post(path, body: body) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get(path, params: {}, timeout: nil)
|
|
38
|
+
@adapter.with_connection do |conn|
|
|
39
|
+
client = timeout ? conn.timeout(timeout) : conn
|
|
40
|
+
if params.empty?
|
|
41
|
+
client.get(path)
|
|
42
|
+
else
|
|
43
|
+
client.get(path, params: params)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def release
|
|
49
|
+
@adapter.release_connection_pool
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Metadata only: never print header values or the auth credential.
|
|
53
|
+
def inspect
|
|
54
|
+
"#<#{self.class} origin=#{@origin} pool_size=#{@pool_size} " \
|
|
55
|
+
"options=[#{@pool_options.keys.join(', ')}]>"
|
|
56
|
+
end
|
|
57
|
+
alias to_s inspect
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build_adapter
|
|
62
|
+
adapter = Object.new
|
|
63
|
+
adapter.extend(HttpConnectionPool::Connectable)
|
|
64
|
+
configure_adapter(adapter)
|
|
65
|
+
adapter
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def configure_adapter(adapter)
|
|
69
|
+
adapter.base_url = @origin
|
|
70
|
+
adapter.pool_size = @pool_size
|
|
71
|
+
adapter.pool_timeout = @pool_timeout if @pool_timeout
|
|
72
|
+
adapter.pool_options = @pool_options
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def pool
|
|
76
|
+
@adapter.connection_pool
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|