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.
@@ -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