noiseless 0.7.2 → 0.9.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/README.md +56 -2
- data/lib/noiseless/adapter.rb +29 -41
- data/lib/noiseless/adapters/elasticsearch.rb +0 -1
- data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +8 -6
- data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +65 -17
- data/lib/noiseless/adapters/execution_modules/http_transport.rb +29 -18
- data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +102 -114
- data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +42 -47
- data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +65 -327
- data/lib/noiseless/adapters/execution_modules/postgresql_query.rb +269 -0
- data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +172 -296
- data/lib/noiseless/adapters/execution_modules/typesense_query.rb +143 -0
- data/lib/noiseless/adapters/indices_api.rb +3 -1
- data/lib/noiseless/adapters/open_search.rb +30 -88
- data/lib/noiseless/adapters/postgresql.rb +6 -16
- data/lib/noiseless/adapters/typesense.rb +1 -2
- data/lib/noiseless/ast/aggregation.rb +0 -23
- data/lib/noiseless/ast/aggregation_builder.rb +28 -0
- data/lib/noiseless/ast/hybrid.rb +4 -2
- data/lib/noiseless/ast/join.rb +4 -5
- data/lib/noiseless/ast/vector.rb +25 -4
- data/lib/noiseless/bulk_importer.rb +17 -32
- data/lib/noiseless/callbacks.rb +4 -30
- data/lib/noiseless/connection_manager.rb +9 -3
- data/lib/noiseless/controller_runtime.rb +37 -0
- data/lib/noiseless/document_manager.rb +22 -41
- data/lib/noiseless/embedding.rb +33 -0
- data/lib/noiseless/instrumentation.rb +2 -136
- data/lib/noiseless/introspection/mermaid_diagrams.rb +143 -0
- data/lib/noiseless/introspection/query_visualizer.rb +3 -228
- data/lib/noiseless/introspection.rb +1 -1
- data/lib/noiseless/log_subscriber.rb +103 -0
- data/lib/noiseless/model.rb +19 -71
- data/lib/noiseless/model_registry.rb +1 -7
- data/lib/noiseless/multi_search.rb +23 -113
- data/lib/noiseless/pagination.rb +14 -40
- data/lib/noiseless/query_builder.rb +16 -16
- data/lib/noiseless/query_delegation.rb +15 -0
- data/lib/noiseless/railtie.rb +0 -2
- data/lib/noiseless/record_class_resolver.rb +15 -0
- data/lib/noiseless/response/records.rb +9 -3
- data/lib/noiseless/runtime_reset_middleware.rb +1 -1
- data/lib/noiseless/search_index_update_job.rb +13 -14
- data/lib/noiseless/test_case.rb +0 -1
- data/lib/noiseless/version.rb +1 -1
- data/lib/noiseless.rb +1 -10
- metadata +33 -13
- data/lib/tasks/benchmark.rake +0 -35
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 195219b6de1210cb1f888a5a322e41c624403b7cab4f07cbd700ca9f5f573196
|
|
4
|
+
data.tar.gz: b92048b781e491ae6c8f0d9114dbf4a4094ba04b7e06bf8394c3a6164e18521a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: afc592e3f9e505ff2272845eb4b8cddaae23543400fe83b1c11c70895fd0b8a537e670c1cc78103df93999f72f51cc0cdb8c008772a59e1e881df024e5a9cf24
|
|
7
|
+
data.tar.gz: 7b4a4113ea30314fbed156161dfa031344881ba17f422ac233cd5a92b91f2b63ff4f7e07282e3a993e98bf42d48d87813b8c4e3aea23ece3365695a1779b382e
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Async-first search abstraction for Rails with multi-backend support (OpenSearch,
|
|
|
6
6
|
|
|
7
7
|
- **Chainable DSL** — fluent query builder with runtime validation
|
|
8
8
|
- **Multi-backend** — OpenSearch, Elasticsearch, Typesense, PostgreSQL adapters
|
|
9
|
-
- **Async-first** — built on Ruby
|
|
9
|
+
- **Async-first** — built on Ruby 4.0+ fiber scheduler with non-blocking I/O
|
|
10
10
|
- **HTTP/2 connection pooling** — persistent connections via `Async::Pool`
|
|
11
11
|
- **Rails integration** — Railtie with log subscriber and controller runtime tracking
|
|
12
12
|
- **Lazy loading** — adapters loaded on-demand, test files excluded from production
|
|
@@ -17,7 +17,7 @@ Async-first search abstraction for Rails with multi-backend support (OpenSearch,
|
|
|
17
17
|
gem "noiseless"
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
`noiseless` is a Rails gem. It requires Ruby >=
|
|
20
|
+
`noiseless` is a Rails gem. It requires Ruby >= 4.0 and Rails >= 8.1.
|
|
21
21
|
|
|
22
22
|
## Configuration
|
|
23
23
|
|
|
@@ -114,6 +114,51 @@ end
|
|
|
114
114
|
|
|
115
115
|
For best performance, run independent searches concurrently within a single `Async` block rather than creating separate `Sync` blocks per search.
|
|
116
116
|
|
|
117
|
+
### Loading Database Records
|
|
118
|
+
|
|
119
|
+
Search classes return search results by default. To load database records, configure
|
|
120
|
+
the record model explicitly and request `response_type: :records`:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
class Company::Search < Noiseless::Model
|
|
124
|
+
index_name 'companies'
|
|
125
|
+
record_class 'Company' # A Class is also accepted; strings resolve lazily.
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
records = Company::Search.search_sync(response_type: :records) do |s|
|
|
129
|
+
s.match(:name, 'tech')
|
|
130
|
+
end
|
|
131
|
+
records.to_a # Company records in search relevance order
|
|
132
|
+
|
|
133
|
+
# The instance API supports the same option.
|
|
134
|
+
Company::Search.new.match(:name, 'tech').execute_sync(response_type: :records)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`record_class` is inherited and does not change the default response type. Index
|
|
138
|
+
names are not used to infer record classes: aliases and versioned indexes need
|
|
139
|
+
not match table names. Requesting `:records` without a model supporting `.where`
|
|
140
|
+
raises `ArgumentError`, even when the search has no hits. Records missing from
|
|
141
|
+
the database are omitted; search totals still describe backend hits.
|
|
142
|
+
|
|
143
|
+
### Pagination and Multi-model Search
|
|
144
|
+
|
|
145
|
+
The paginator executes lazily and waits for its response when results or totals
|
|
146
|
+
are accessed. Multi-search also waits and returns a `MultiSearchResponse`, not
|
|
147
|
+
an `Async::Task`. Both work inside an existing async context.
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
page = Company::Search.page(2).per(10)
|
|
151
|
+
page.to_a
|
|
152
|
+
page.total_count
|
|
153
|
+
|
|
154
|
+
response = Noiseless.multi_search(models: [Company::Search, Product::Search]) do |s|
|
|
155
|
+
s.match(:name, 'tech')
|
|
156
|
+
s.limit(10)
|
|
157
|
+
end
|
|
158
|
+
response.results_for_model(Company::Search)
|
|
159
|
+
response.records_for_model(Company::Search) # Uses its configured record_class.
|
|
160
|
+
```
|
|
161
|
+
|
|
117
162
|
### Advanced Queries
|
|
118
163
|
|
|
119
164
|
```ruby
|
|
@@ -179,6 +224,15 @@ end
|
|
|
179
224
|
|
|
180
225
|
### Running Tests Locally
|
|
181
226
|
|
|
227
|
+
Run the pagination, multi-search, and record-loading unit tests without services:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
bundle exec ruby -Itest -Ilib -e 'ARGV.each { require_relative it }' \
|
|
231
|
+
test/{search_pagination,multi_search,record_response,active_record_response,postgresql_search_contract,query_contract}_test.rb
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
For the full integration suite:
|
|
235
|
+
|
|
182
236
|
```bash
|
|
183
237
|
docker compose up -d postgres elasticsearch opensearch typesense
|
|
184
238
|
bin/test
|
data/lib/noiseless/adapter.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "async"
|
|
4
4
|
require "json"
|
|
5
|
-
require_relative "introspection"
|
|
6
5
|
|
|
7
6
|
module Noiseless
|
|
8
7
|
class Adapter
|
|
@@ -127,6 +126,8 @@ module Noiseless
|
|
|
127
126
|
|
|
128
127
|
private
|
|
129
128
|
|
|
129
|
+
def instance_variables_to_inspect = instance_variables - %i[@connection_params]
|
|
130
|
+
|
|
130
131
|
# Convert AST to Hash - override in subclasses for adapter-specific format
|
|
131
132
|
def ast_to_hash(ast_node)
|
|
132
133
|
result = {}
|
|
@@ -153,14 +154,8 @@ module Noiseless
|
|
|
153
154
|
# Aggregations
|
|
154
155
|
result[:aggs] = build_aggregations_hash(ast_node.aggregations) if ast_node.aggregations.any?
|
|
155
156
|
|
|
156
|
-
|
|
157
|
-
result[:
|
|
158
|
-
|
|
159
|
-
# Hybrid search (combines text + vector with RRF or weighted scoring)
|
|
160
|
-
if ast_node.hybrid_search?
|
|
161
|
-
hybrid_config = build_hybrid_query(ast_node.hybrid)
|
|
162
|
-
result.merge!(hybrid_config)
|
|
163
|
-
end
|
|
157
|
+
result[:knn] = build_knn_query(ast_node.vector, filters: result.dig(:query, :bool, :filter)) if ast_node.vector_search?
|
|
158
|
+
result[:query] = build_hybrid_query(ast_node.hybrid, result[:query]) if ast_node.hybrid_search?
|
|
164
159
|
|
|
165
160
|
# Search pipeline (OpenSearch only)
|
|
166
161
|
result[:search_pipeline] = ast_node.pipeline if ast_node.has_pipeline?
|
|
@@ -168,36 +163,27 @@ module Noiseless
|
|
|
168
163
|
result
|
|
169
164
|
end
|
|
170
165
|
|
|
171
|
-
def build_knn_query(vector_node)
|
|
166
|
+
def build_knn_query(vector_node, filters: nil)
|
|
172
167
|
{
|
|
173
168
|
field: vector_node.field.to_s,
|
|
174
|
-
query_vector: vector_node.embedding,
|
|
169
|
+
query_vector: encode_vector(vector_node.embedding),
|
|
175
170
|
k: vector_node.k,
|
|
176
|
-
|
|
177
|
-
}
|
|
171
|
+
filter: filters.presence
|
|
172
|
+
}.compact
|
|
178
173
|
end
|
|
179
174
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
{
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
},
|
|
194
|
-
knn: build_knn_query(hybrid_node.vector),
|
|
195
|
-
rank: {
|
|
196
|
-
rrf: {
|
|
197
|
-
window_size: hybrid_node.vector.k * 2
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
175
|
+
def build_hybrid_query(hybrid_node, query)
|
|
176
|
+
bool = query&.fetch(:bool, nil)&.dup || {}
|
|
177
|
+
text = { multi_match: { query: hybrid_node.text_query, fields: hybrid_node.fields.presence, boost: hybrid_node.text_weight }.compact }
|
|
178
|
+
vector = { knn: build_knn_query(hybrid_node.vector, filters: bool[:filter]).merge(boost: hybrid_node.vector_weight) }
|
|
179
|
+
|
|
180
|
+
{ bool: bool.merge(should: [text, vector], minimum_should_match: 1) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def encode_vector(values) = [values.pack("g*")].pack("m0")
|
|
184
|
+
|
|
185
|
+
def encode_document(document)
|
|
186
|
+
document.to_h.transform_values { it.is_a?(Noiseless::Embedding) ? encode_vector(it.values) : it }
|
|
201
187
|
end
|
|
202
188
|
|
|
203
189
|
def build_query_hash(bool_node)
|
|
@@ -241,12 +227,7 @@ module Noiseless
|
|
|
241
227
|
end
|
|
242
228
|
|
|
243
229
|
def build_pagination_hash(paginate_node)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
{
|
|
247
|
-
from: (paginate_node.page - 1) * paginate_node.per_page,
|
|
248
|
-
size: paginate_node.per_page
|
|
249
|
-
}
|
|
230
|
+
Pagination.query_options(paginate_node)
|
|
250
231
|
end
|
|
251
232
|
|
|
252
233
|
# Parses a backend HTTP response, raising when the backend reported an error.
|
|
@@ -262,7 +243,7 @@ module Noiseless
|
|
|
262
243
|
rescue JSON::ParserError, TypeError
|
|
263
244
|
nil
|
|
264
245
|
end
|
|
265
|
-
error = payload.is_a?(Hash) ? payload["error"] : nil
|
|
246
|
+
error = payload.is_a?(Hash) ? payload["error"] || payload["message"] : nil
|
|
266
247
|
reason = if error.is_a?(Hash)
|
|
267
248
|
[error["type"], error["reason"]].compact.join(": ")
|
|
268
249
|
elsif error
|
|
@@ -274,6 +255,13 @@ module Noiseless
|
|
|
274
255
|
raise error_class.new(message, status: response.status, error_type: error.is_a?(Hash) ? error["type"] : nil)
|
|
275
256
|
end
|
|
276
257
|
|
|
258
|
+
def exists_response?(response, context:)
|
|
259
|
+
return true if response.success?
|
|
260
|
+
return false if response.status == 404
|
|
261
|
+
|
|
262
|
+
raise Noiseless::RequestError.new("#{context}: HTTP #{response.status}", status: response.status)
|
|
263
|
+
end
|
|
264
|
+
|
|
277
265
|
# Override in subclasses
|
|
278
266
|
def execute_search(_query_hash, **_opts)
|
|
279
267
|
{
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require_relative "es_compatible_execution"
|
|
5
4
|
|
|
6
5
|
module Noiseless
|
|
7
6
|
module Adapters
|
|
@@ -13,7 +12,7 @@ module Noiseless
|
|
|
13
12
|
|
|
14
13
|
def execute_search(query_hash, indexes: [], **_opts)
|
|
15
14
|
path = indexes.any? ? "/#{indexes.join(',')}/_search" : "/_search"
|
|
16
|
-
body =
|
|
15
|
+
body = dump_json(query_hash)
|
|
17
16
|
|
|
18
17
|
response = post_request(path, body)
|
|
19
18
|
parse_json_response!(response, error_class: Noiseless::SearchError, context: "search")
|
|
@@ -26,15 +25,15 @@ module Noiseless
|
|
|
26
25
|
body[:mappings] = mappings if mappings
|
|
27
26
|
body[:settings] = settings if settings
|
|
28
27
|
|
|
29
|
-
response = put_request("/#{index_name}", body.any? ?
|
|
28
|
+
response = put_request("/#{index_name}", body.any? ? dump_json(body) : nil)
|
|
30
29
|
parse_json_response!(response, context: "create index #{index_name}")
|
|
31
30
|
ensure
|
|
32
31
|
response&.close
|
|
33
32
|
end
|
|
34
33
|
|
|
35
|
-
def execute_index_document(index, id, document, **_opts)
|
|
36
|
-
path = id ? "/#{index}/_doc/#{id}" : "/#{index}/_doc"
|
|
37
|
-
body =
|
|
34
|
+
def execute_index_document(index, id, document, refresh: nil, **_opts)
|
|
35
|
+
path = (id ? "/#{index}/_doc/#{id}" : "/#{index}/_doc") + query_string(refresh:)
|
|
36
|
+
body = dump_json(encode_document(document))
|
|
38
37
|
|
|
39
38
|
response = id ? put_request(path, body) : post_request(path, body)
|
|
40
39
|
parse_json_response!(response, context: "index document #{index}/#{id}")
|
|
@@ -42,6 +41,9 @@ module Noiseless
|
|
|
42
41
|
response&.close
|
|
43
42
|
end
|
|
44
43
|
|
|
44
|
+
def pit_path = "_pit"
|
|
45
|
+
def pit_close_body(pit_id) = { id: pit_id }
|
|
46
|
+
|
|
45
47
|
def execute_cluster_health(**_opts)
|
|
46
48
|
response = get_request("/_cluster/health")
|
|
47
49
|
JSON.parse(response.read)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require_relative "http_transport"
|
|
5
4
|
|
|
6
5
|
module Noiseless
|
|
7
6
|
module Adapters
|
|
@@ -11,20 +10,67 @@ module Noiseless
|
|
|
11
10
|
module EsCompatibleExecution
|
|
12
11
|
include HttpTransport
|
|
13
12
|
|
|
13
|
+
# Pages through every hit of a query with a point in time and
|
|
14
|
+
# search_after, which unlike from/size has no depth limit and sees a
|
|
15
|
+
# consistent snapshot. The point in time is always closed.
|
|
16
|
+
def each_page(ast_node, keep_alive: "1m", model_class: nil, response_type: nil)
|
|
17
|
+
return enum_for(__method__, ast_node, keep_alive:, model_class:, response_type:) unless block_given?
|
|
18
|
+
|
|
19
|
+
Sync do
|
|
20
|
+
query = ast_to_hash(ast_node).except(:from)
|
|
21
|
+
query[:sort] = [*query[:sort], { _shard_doc: "asc" }]
|
|
22
|
+
pit_id = execute_open_pit(ast_node.indexes, keep_alive:)
|
|
23
|
+
|
|
24
|
+
loop do
|
|
25
|
+
raw = execute_pit_search(query.merge(pit: { id: pit_id, keep_alive: }))
|
|
26
|
+
hits = raw.dig("hits", "hits")
|
|
27
|
+
break if hits.empty?
|
|
28
|
+
|
|
29
|
+
yield ResponseFactory.create(raw, model_class:, response_type:)
|
|
30
|
+
pit_id = raw["pit_id"] || pit_id
|
|
31
|
+
query[:search_after] = hits.last["sort"]
|
|
32
|
+
end
|
|
33
|
+
ensure
|
|
34
|
+
execute_close_pit(pit_id) if pit_id
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
14
38
|
private
|
|
15
39
|
|
|
16
|
-
def
|
|
40
|
+
def execute_open_pit(indexes, keep_alive:)
|
|
41
|
+
response = post_request("/#{indexes.join(',')}/#{pit_path}?keep_alive=#{keep_alive}", nil)
|
|
42
|
+
parse_json_response!(response, context: "open point in time").then { it["pit_id"] || it["id"] }
|
|
43
|
+
ensure
|
|
44
|
+
response&.close
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def execute_close_pit(pit_id)
|
|
48
|
+
response = delete_request("/#{pit_path}", dump_json(pit_close_body(pit_id)))
|
|
49
|
+
parse_json_response!(response, context: "close point in time")
|
|
50
|
+
ensure
|
|
51
|
+
response&.close
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def execute_pit_search(query_hash)
|
|
55
|
+
response = post_request("/_search", dump_json(query_hash))
|
|
56
|
+
parse_json_response!(response, error_class: Noiseless::SearchError, context: "point-in-time search")
|
|
57
|
+
ensure
|
|
58
|
+
response&.close
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def execute_bulk(actions, refresh: nil, **_opts)
|
|
17
62
|
body = actions.map do |action|
|
|
18
63
|
if action[:index]
|
|
19
64
|
action_line = { index: { _index: action[:index][:_index], _id: action[:index][:_id] } }
|
|
20
|
-
data_line = action[:index][:data]
|
|
21
|
-
"#{
|
|
65
|
+
data_line = encode_document(action[:index][:data])
|
|
66
|
+
"#{dump_json(action_line)}\n#{dump_json(data_line)}\n"
|
|
22
67
|
else
|
|
23
|
-
"#{
|
|
68
|
+
"#{dump_json(action)}\n"
|
|
24
69
|
end
|
|
25
70
|
end.join
|
|
26
71
|
|
|
27
|
-
|
|
72
|
+
path = "/_bulk#{query_string(refresh:, filter_path: 'errors,items.*.status,items.*.error')}"
|
|
73
|
+
response = post_request(path, body, content_type: "application/x-ndjson")
|
|
28
74
|
parse_json_response!(response, context: "bulk")
|
|
29
75
|
ensure
|
|
30
76
|
response&.close
|
|
@@ -41,6 +87,12 @@ module Noiseless
|
|
|
41
87
|
response&.close
|
|
42
88
|
end
|
|
43
89
|
|
|
90
|
+
def query_string(refresh: nil, **params)
|
|
91
|
+
params = params.compact
|
|
92
|
+
params[:refresh] = refresh if refresh
|
|
93
|
+
params.empty? ? "" : "?#{params.map { |key, value| "#{key}=#{value}" }.join('&')}"
|
|
94
|
+
end
|
|
95
|
+
|
|
44
96
|
def execute_refresh_index(index_name)
|
|
45
97
|
response = post_request("/#{index_name}/_refresh", nil)
|
|
46
98
|
parse_json_response!(response, context: "refresh index #{index_name}")
|
|
@@ -50,24 +102,22 @@ module Noiseless
|
|
|
50
102
|
|
|
51
103
|
def execute_index_exists?(index_name)
|
|
52
104
|
response = head_request("/#{index_name}")
|
|
53
|
-
response
|
|
54
|
-
rescue StandardError
|
|
55
|
-
false
|
|
105
|
+
exists_response?(response, context: "index exists #{index_name}")
|
|
56
106
|
ensure
|
|
57
107
|
response&.close
|
|
58
108
|
end
|
|
59
109
|
|
|
60
|
-
def execute_update_document(index, id, changes, **_opts)
|
|
61
|
-
body =
|
|
110
|
+
def execute_update_document(index, id, changes, refresh: nil, **_opts)
|
|
111
|
+
body = dump_json(doc: encode_document(changes))
|
|
62
112
|
|
|
63
|
-
response = post_request("/#{index}/_update/#{id}", body)
|
|
113
|
+
response = post_request("/#{index}/_update/#{id}#{query_string(refresh:)}", body)
|
|
64
114
|
parse_json_response!(response, context: "update document #{index}/#{id}")
|
|
65
115
|
ensure
|
|
66
116
|
response&.close
|
|
67
117
|
end
|
|
68
118
|
|
|
69
|
-
def execute_delete_document(index, id, **_opts)
|
|
70
|
-
response = delete_request("/#{index}/_doc/#{id}")
|
|
119
|
+
def execute_delete_document(index, id, refresh: nil, **_opts)
|
|
120
|
+
response = delete_request("/#{index}/_doc/#{id}#{query_string(refresh:)}")
|
|
71
121
|
# 404 covers both a missing document and a missing index; either way
|
|
72
122
|
# the delete is idempotent.
|
|
73
123
|
return { "_index" => index, "_id" => id, "result" => "not_found" } if response.status == 404
|
|
@@ -79,9 +129,7 @@ module Noiseless
|
|
|
79
129
|
|
|
80
130
|
def execute_document_exists?(index, id)
|
|
81
131
|
response = head_request("/#{index}/_doc/#{id}")
|
|
82
|
-
response
|
|
83
|
-
rescue StandardError
|
|
84
|
-
false
|
|
132
|
+
exists_response?(response, context: "document exists #{index}/#{id}")
|
|
85
133
|
ensure
|
|
86
134
|
response&.close
|
|
87
135
|
end
|
|
@@ -19,7 +19,8 @@ module Noiseless
|
|
|
19
19
|
SocketError,
|
|
20
20
|
IOError,
|
|
21
21
|
IO::TimeoutError,
|
|
22
|
-
Timeout::Error
|
|
22
|
+
Timeout::Error,
|
|
23
|
+
Protocol::HTTP::Error
|
|
23
24
|
].freeze
|
|
24
25
|
|
|
25
26
|
# Default per-operation IO timeout (seconds) for the search backend.
|
|
@@ -39,13 +40,29 @@ module Noiseless
|
|
|
39
40
|
# deadline (long bulk imports may need a higher value or nil).
|
|
40
41
|
DEFAULT_REQUEST_TIMEOUT = 30
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
# Default cap on concurrent connections per host. Without it the pool
|
|
44
|
+
# opens one socket per concurrent request. Waiting for a free
|
|
45
|
+
# connection counts against request_timeout. Override per-connection
|
|
46
|
+
# with +pool_limit:+.
|
|
47
|
+
DEFAULT_POOL_LIMIT = 16
|
|
48
|
+
|
|
49
|
+
# json 3 serializes objects it does not know with to_s, so a Time
|
|
50
|
+
# became "2025-12-11 14:18:40 UTC", which search engines reject.
|
|
51
|
+
# Falling back to as_json keeps ActiveSupport's ISO 8601 times.
|
|
52
|
+
JSON_CODER = JSON::Coder.new { |object| object.respond_to?(:as_json) ? object.as_json : object.to_s }
|
|
53
|
+
|
|
54
|
+
BufferedResponse = Data.define(:status, :body) do
|
|
43
55
|
def read = body
|
|
44
56
|
def success? = (200..299).cover?(status)
|
|
45
57
|
def close = nil
|
|
46
58
|
end
|
|
47
59
|
|
|
48
|
-
def initialize(hosts: [], timeout: DEFAULT_TIMEOUT, request_timeout: DEFAULT_REQUEST_TIMEOUT,
|
|
60
|
+
def initialize(hosts: [], timeout: DEFAULT_TIMEOUT, request_timeout: DEFAULT_REQUEST_TIMEOUT,
|
|
61
|
+
pool_limit: DEFAULT_POOL_LIMIT, **connection_params)
|
|
62
|
+
unless request_timeout.nil? || (request_timeout.is_a?(Numeric) && request_timeout.positive? && request_timeout.finite?)
|
|
63
|
+
raise ArgumentError, "request_timeout must be a positive finite number or nil, got #{request_timeout.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
# Ensure we always have at least one host
|
|
50
67
|
hosts_array = Array(hosts)
|
|
51
68
|
@hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
|
|
@@ -59,7 +76,7 @@ module Noiseless
|
|
|
59
76
|
@clients = {}
|
|
60
77
|
@hosts.each do |host|
|
|
61
78
|
endpoint = Async::HTTP::Endpoint.parse(host, timeout: @timeout)
|
|
62
|
-
@clients[host] = Async::HTTP::Client.new(endpoint)
|
|
79
|
+
@clients[host] = Async::HTTP::Client.new(endpoint, limit: pool_limit)
|
|
63
80
|
end
|
|
64
81
|
|
|
65
82
|
super(hosts: @hosts, **connection_params)
|
|
@@ -78,27 +95,19 @@ module Noiseless
|
|
|
78
95
|
end
|
|
79
96
|
end
|
|
80
97
|
|
|
81
|
-
def post_request(path, body, content_type: "application/json")
|
|
82
|
-
|
|
98
|
+
def post_request(path, body, content_type: "application/json") = body_request(:post, path, body, content_type)
|
|
99
|
+
def put_request(path, body, content_type: "application/json") = body_request(:put, path, body, content_type)
|
|
100
|
+
def patch_request(path, body, content_type: "application/json") = body_request(:patch, path, body, content_type)
|
|
83
101
|
|
|
84
|
-
|
|
85
|
-
client.post(path, headers, body)
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def put_request(path, body, content_type: "application/json")
|
|
102
|
+
def body_request(verb, path, body, content_type)
|
|
90
103
|
headers = body ? default_headers + [["content-type", content_type]] : default_headers
|
|
91
104
|
|
|
92
105
|
with_client do |client|
|
|
93
|
-
client.
|
|
106
|
+
client.public_send(verb, path, headers, body)
|
|
94
107
|
end
|
|
95
108
|
end
|
|
96
109
|
|
|
97
|
-
def delete_request(path)
|
|
98
|
-
with_client do |client|
|
|
99
|
-
client.delete(path, default_headers)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
110
|
+
def delete_request(path, body = nil) = body_request(:delete, path, body, "application/json")
|
|
102
111
|
|
|
103
112
|
def head_request(path)
|
|
104
113
|
with_client do |client|
|
|
@@ -146,6 +155,8 @@ module Noiseless
|
|
|
146
155
|
"search backend unreachable#{location} (#{e.class}: #{e.message})"
|
|
147
156
|
end
|
|
148
157
|
|
|
158
|
+
def dump_json(object) = JSON_CODER.dump(object)
|
|
159
|
+
|
|
149
160
|
def default_headers
|
|
150
161
|
[
|
|
151
162
|
["accept", "application/json"],
|