search-engine-for-typesense 30.9.1 → 30.9.3
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/CHANGELOG.md +19 -0
- data/README.md +51 -16
- data/lib/search_engine/active_record_syncable.rb +85 -197
- data/lib/search_engine/ast/node.rb +3 -6
- data/lib/search_engine/async_partition_coordinator.rb +5 -3
- data/lib/search_engine/client/services/search.rb +2 -0
- data/lib/search_engine/client.rb +9 -3
- data/lib/search_engine/compiler.rb +33 -128
- data/lib/search_engine/deletion.rb +12 -9
- data/lib/search_engine/dsl/parser.rb +51 -71
- data/lib/search_engine/engine.rb +25 -16
- data/lib/search_engine/filters/sanitizer.rb +162 -126
- data/lib/search_engine/hydration/materializers.rb +53 -118
- data/{app/search_engine → lib}/search_engine/index_partition_job.rb +1 -1
- data/lib/search_engine/indexing_run_store/rails_cache.rb +44 -33
- data/lib/search_engine/indexing_run_store.rb +10 -0
- data/lib/search_engine/joins/guard.rb +14 -8
- data/lib/search_engine/multi.rb +92 -6
- data/lib/search_engine/multi_result.rb +40 -23
- data/lib/search_engine/notifications/compact_logger.rb +2 -2
- data/lib/search_engine/observability.rb +6 -6
- data/lib/search_engine/postgres_outbox/database_queries.rb +17 -0
- data/lib/search_engine/postgres_outbox/drainer.rb +7 -12
- data/lib/search_engine/postgres_outbox/event.rb +12 -0
- data/lib/search_engine/postgres_outbox/materialization_cursor.rb +25 -11
- data/lib/search_engine/postgres_outbox/repository.rb +65 -115
- data/lib/search_engine/postgres_outbox.rb +2 -0
- data/lib/search_engine/registry.rb +27 -28
- data/lib/search_engine/relation/compiler.rb +18 -33
- data/lib/search_engine/relation/dsl/filters.rb +71 -175
- data/lib/search_engine/relation/dx.rb +3 -1
- data/lib/search_engine/relation/materializers.rb +6 -3
- data/lib/search_engine/relation/options.rb +5 -26
- data/lib/search_engine/relation/state.rb +3 -2
- data/lib/search_engine/relation.rb +5 -4
- data/lib/search_engine/result.rb +33 -2
- data/lib/search_engine/schema.rb +23 -24
- data/lib/search_engine/test/offline_client.rb +2 -2
- data/lib/search_engine/test/stub_client.rb +9 -16
- data/lib/search_engine/update.rb +1 -1
- data/lib/search_engine/version.rb +1 -1
- data/lib/search_engine.rb +11 -149
- data/lib/tasks/search_engine.rake +3 -5
- metadata +18 -3
- /data/{app/search_engine → lib}/search_engine/postgres_outbox/drain_job.rb +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8839a1d87b60901a030030a293152432bbf593b215ba7fa7292eac10e724a2bb
|
|
4
|
+
data.tar.gz: 84b2e507deee029c7ef87165a5f5ca2f6a37450fbf25b59bb6208a42c0d4c25b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e0bb71bd0d9ff89f7053286c6b683433e0e49ab9e5d2cd514e2bc778f33eff821236f840b14358922afb4cc091802800616290d94a807845a87528f7f62959ff
|
|
7
|
+
data.tar.gz: 79199d5c13cdbbc4acb8b2a5c2570e6934bd675447e5ea8d4da517f68047d3fc0060e16af69f929686b8c8ebe84e298f498e8a0b720c25bb57d1e41098700ab6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 30.9.3
|
|
4
|
+
|
|
5
|
+
- Preserve tenant filters and scoped clients across searches and bulk writes. Reject ambiguous literals,
|
|
6
|
+
use the joined collection’s field types, and keep bound values out of parser errors.
|
|
7
|
+
- Honor exact offsets, fetch one hit for `find_by`, and hydrate selected hits once. Typed multi-search
|
|
8
|
+
reports individual errors while raw multi-search preserves the server response.
|
|
9
|
+
- Stop schema cutover on incomplete imports and retain both collections after an uncertain alias write.
|
|
10
|
+
Conditional rollback requires an exclusive guard shared by all alias writers.
|
|
11
|
+
- Fence PostgreSQL outbox acknowledgements by the active claim. Drain and restart older workers during rollout.
|
|
12
|
+
Partition workers update their own entry without reading the full run.
|
|
13
|
+
- Preserve mapped document IDs across STI, Rails autoloading and reload. Inline hosts can load Active Job later.
|
|
14
|
+
|
|
15
|
+
## 30.9.2
|
|
16
|
+
|
|
17
|
+
- Check recent event IDs during catch-up so a returning delivery target can discover new work
|
|
18
|
+
while its saved position moves through older events. Collection quotas still apply.
|
|
19
|
+
- Read outbox checkpoints, delivery state and row locks directly from the database on every poll,
|
|
20
|
+
including when Active Record query caching is enabled on Rails 6.1 and 7.0.
|
|
21
|
+
|
|
3
22
|
## 30.9.1
|
|
4
23
|
|
|
5
24
|
- Serialize PostgreSQL delivery discovery across schedulers and workers and check delivery membership
|
data/README.md
CHANGED
|
@@ -52,7 +52,13 @@ See [Quickstart](https://nikita-shkoda.mintlify.app/projects/search-engine-for-t
|
|
|
52
52
|
|
|
53
53
|
By default, the gem manages a dedicated Zeitwerk loader for your SearchEngine models under `app/search_engine/`. The loader is initialized after Rails so that application models/constants are available, auto-reloads in development, and is eager-loaded in production/test.
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
Mappers under the host’s `app/models/search_engine` are registered by the Rails loader at boot and reload,
|
|
56
|
+
including nested classes whose names differ from their declared collection. For a mapper managed outside
|
|
57
|
+
these model directories, reference it in the callback declaration, for example
|
|
58
|
+
`search_engine_syncable collection: Catalog::ItemIndex.collection`. This loads its identity strategy before
|
|
59
|
+
the first callback. A bare collection uses the Active Record primary key for deletes.
|
|
60
|
+
|
|
61
|
+
Configure the dedicated loader with:
|
|
56
62
|
|
|
57
63
|
```ruby
|
|
58
64
|
# config/initializers/search_engine.rb
|
|
@@ -66,9 +72,22 @@ SQL sources stream PostgreSQL queries in their own read-only transaction. Start
|
|
|
66
72
|
outside an existing transaction; an active transaction is rejected before cursor SQL runs.
|
|
67
73
|
The configured statement timeout applies only to the streaming transaction.
|
|
68
74
|
|
|
75
|
+
Use hash predicates or bound placeholders for application values. A raw filter string is a Typesense
|
|
76
|
+
expression and must come from trusted code. Values that Typesense cannot represent as exact literals,
|
|
77
|
+
including a wildcard document ID or a string containing a backtick, raise before a search or bulk write.
|
|
78
|
+
Numeric comparisons in `where.not` invert the operator.
|
|
79
|
+
A relation with `limit(2).offset(1)` starts at the second matching document. An explicit `page` or
|
|
80
|
+
`per` takes precedence over the offset. Use a stable sort when reading successive windows.
|
|
81
|
+
|
|
69
82
|
Joined filters execute as native Typesense joins and require valid collection references.
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
The target model's field type determines how Ruby dates and times are encoded. For a target
|
|
84
|
+
without a declared field type, pass the stored value as an ISO string or integer epoch.
|
|
85
|
+
Search configuration and missing-reference errors propagate to the caller.
|
|
86
|
+
|
|
87
|
+
Single search, federated search and multi-page `all!` use the configured client. A failing entry in
|
|
88
|
+
`multi_search` or `multi_search_result` raises with the entry label and provider status.
|
|
89
|
+
Use `multi_search_raw` to handle successful and failed entries separately. Setting `.cache(false)`
|
|
90
|
+
on an entry disables caching for the whole federated request.
|
|
72
91
|
|
|
73
92
|
## Usage examples
|
|
74
93
|
|
|
@@ -230,8 +249,8 @@ bundle exec ruby examples/demo_shop/bin/query_smoke
|
|
|
230
249
|
This compiles geo/vector requests and checks metadata fixtures without HTTP requests or model downloads.
|
|
231
250
|
See [the demo instructions](examples/demo_shop/README.md#query-examples) for explicitly isolated live execution,
|
|
232
251
|
including highlighting through `Client#search` and `hit.snippet_for`.
|
|
233
|
-
CI
|
|
234
|
-
|
|
252
|
+
CI runs query, cache, schema and demo checks against Typesense 30.2, including the built-in embedding
|
|
253
|
+
model. A separate PostgreSQL job exercises outbox claims and concurrency.
|
|
235
254
|
|
|
236
255
|
`upsert_bulk` defaults to `on_failure: :raise`. Use the exact symbol `:return` when the caller needs to
|
|
237
256
|
handle valid Typesense row failures itself. Malformed responses and response/document count mismatches always
|
|
@@ -301,7 +320,8 @@ only then lets the schema lifecycle swap the alias. If any partition fails or ti
|
|
|
301
320
|
alias target remains active.
|
|
302
321
|
|
|
303
322
|
Use a shared `Rails.cache` backend, or provide `c.indexer.partition_run_store`, so worker processes and
|
|
304
|
-
the parent indexing process can see the same run metadata.
|
|
323
|
+
the parent indexing process can see the same run metadata. Worker transitions update one partition.
|
|
324
|
+
Explicit snapshots read the complete run. Size the queue carefully: worker concurrency
|
|
305
325
|
multiplies with any per-partition `max_parallel` setting.
|
|
306
326
|
|
|
307
327
|
A custom partition run store must implement:
|
|
@@ -332,8 +352,10 @@ available.
|
|
|
332
352
|
collection creation, indexing, alias swap, and retention. `SearchEngine::Schema.rollback` uses the same guard
|
|
333
353
|
around target resolution, validation, and its alias swap. In-place schema updates intentionally do not invoke
|
|
334
354
|
the guard. The guard must yield exactly once; not yielding or yielding repeatedly raises `ArgumentError`.
|
|
335
|
-
|
|
336
|
-
|
|
355
|
+
The schema apply task requires every partition import to succeed before it publishes the new physical
|
|
356
|
+
collection. On failure before the alias write, cleanup removes the candidate once it verifies the target. If the alias write raises,
|
|
357
|
+
the server may already have accepted it. Both physical collections are retained. Check the live alias
|
|
358
|
+
and the candidate before pruning or retrying.
|
|
337
359
|
|
|
338
360
|
In delivery-target outbox deployments, use the guard to stop new target claims, wait for already-claimed
|
|
339
361
|
deliveries to acknowledge, and prevent cooperating direct writers from crossing the alias swap:
|
|
@@ -384,8 +406,10 @@ points to after a later cutover.
|
|
|
384
406
|
Rollback is conservative and retry-safe. The backward-compatible form rolls back one generation only when the
|
|
385
407
|
alias points at the newest retained physical; once it no longer does, repeating the call returns
|
|
386
408
|
`action: :already_rolled_back` without another swap. When newer orphaned physicals exist, or when an operator
|
|
387
|
-
needs an auditable destination, pass the retained target explicitly.
|
|
388
|
-
|
|
409
|
+
needs an auditable destination, pass the retained target explicitly. To require a specific source, pass
|
|
410
|
+
`expected_current` and configure `schema.around_rebuild` to acquire an exclusive, cross-process lock.
|
|
411
|
+
Every writer of that logical alias must share the lock. Typesense exposes separate read and write operations,
|
|
412
|
+
so direct administrative writes bypass the precondition.
|
|
389
413
|
|
|
390
414
|
```ruby
|
|
391
415
|
SearchEngine::Schema.rollback(
|
|
@@ -402,9 +426,11 @@ rails 'search_engine:schema:rollback[products]'
|
|
|
402
426
|
rails 'search_engine:schema:rollback[products,products_20250101_000000_001,products_20250102_000000_001]'
|
|
403
427
|
```
|
|
404
428
|
|
|
405
|
-
An explicit destination must be a retained physical older than the current
|
|
406
|
-
|
|
407
|
-
|
|
429
|
+
An explicit destination must be a retained physical older than the current or expected source. A retry
|
|
430
|
+
succeeds without another swap when the alias already points at the destination and the named generations
|
|
431
|
+
are still retained. Any other expected-current mismatch raises
|
|
432
|
+
`SearchEngine::Schema::RollbackConflict`. After an ambiguous alias response, reconcile the remote state
|
|
433
|
+
before allowing another writer to proceed.
|
|
408
434
|
|
|
409
435
|
This protocol only gates delivery-target claims. Legacy single-target event claims have no target identity and
|
|
410
436
|
must be stopped before a rebuild. Likewise, an unguarded direct writer can still cross the swap. In
|
|
@@ -572,9 +598,10 @@ For large histories, create a checkpoint table in a migration with
|
|
|
572
598
|
`c.postgres_outbox.materialization_cursor_table_name = 'search_engine_outbox_materialization_cursors'`.
|
|
573
599
|
Run the migration before starting consumers with this setting.
|
|
574
600
|
|
|
575
|
-
With checkpoints, each discovery call checks at most
|
|
576
|
-
|
|
577
|
-
|
|
601
|
+
With checkpoints, each discovery call checks at most 2,000 event IDs. Each target set maintains
|
|
602
|
+
its own forward position and a historical sweep. The forward scan overlaps its previous range
|
|
603
|
+
to catch nearby out-of-order commits. When it falls behind, part of the historical scan budget
|
|
604
|
+
checks the newest IDs while the forward scan continues through the intervening events. A
|
|
578
605
|
transaction that commits farther behind, or an older event that becomes due, can wait until
|
|
579
606
|
the historical scan revisits its ID. Sweep time depends on retained history and polling rate.
|
|
580
607
|
Collection batch quotas and the latest-event rule still govern which deliveries may be created.
|
|
@@ -642,6 +669,14 @@ Drain slots are scheduler bookkeeping and are deliberately not changed. Configur
|
|
|
642
669
|
before each database mutation, but Ruby configuration and PostgreSQL cannot share an atomic lock, so do not
|
|
643
670
|
change delivery-target configuration concurrently with retirement.
|
|
644
671
|
|
|
672
|
+
Single-target repository callers must pass the events returned by `claim_pending` to acknowledgement and
|
|
673
|
+
failure methods. The database matches each event against its current processing claim. Expired workers
|
|
674
|
+
can finish an external request, but only the current owner can change the event state. Drain and restart
|
|
675
|
+
workers running earlier versions when enabling this protection.
|
|
676
|
+
|
|
677
|
+
Active Record subclasses inherit the parent’s sync configuration and may override it. Callbacks resolve
|
|
678
|
+
the current mapper at execution time, including the document identity used for destroy.
|
|
679
|
+
|
|
645
680
|
Pair triggered source models with `sync_strategy: :postgres_outbox` so Active Record callbacks do not also
|
|
646
681
|
write to Typesense for the same changes:
|
|
647
682
|
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'active_support/concern'
|
|
4
|
+
require 'active_support/core_ext/class/attribute'
|
|
5
|
+
require 'active_support/inflector'
|
|
4
6
|
|
|
5
7
|
module SearchEngine
|
|
6
8
|
# ActiveRecord concern to keep a Typesense collection in sync.
|
|
7
9
|
#
|
|
8
10
|
# Include into an AR model and call {.search_engine_syncable} to store sync
|
|
9
|
-
# metadata. The default callback strategy
|
|
10
|
-
#
|
|
11
|
-
#
|
|
11
|
+
# metadata. The default callback strategy upserts on create/update and deletes
|
|
12
|
+
# on destroy. The postgres_outbox strategy stores metadata without installing
|
|
13
|
+
# callbacks. Subclasses inherit the configuration unless they declare their own.
|
|
12
14
|
#
|
|
13
15
|
# @example
|
|
14
16
|
# class Product < ApplicationRecord
|
|
@@ -18,86 +20,61 @@ module SearchEngine
|
|
|
18
20
|
module ActiveRecordSyncable
|
|
19
21
|
extend ActiveSupport::Concern
|
|
20
22
|
|
|
23
|
+
# Rails deduplicates after_commit filters by method name, even for separate actions.
|
|
24
|
+
CALLBACKS = {
|
|
25
|
+
create: :__se_syncable_create!,
|
|
26
|
+
update: :__se_syncable_upsert!,
|
|
27
|
+
destroy: :__se_syncable_delete!
|
|
28
|
+
}.freeze
|
|
29
|
+
private_constant :CALLBACKS
|
|
30
|
+
|
|
31
|
+
included do
|
|
32
|
+
class_attribute :_se_syncable_config, instance_writer: false, instance_predicate: false
|
|
33
|
+
end
|
|
34
|
+
|
|
21
35
|
class_methods do
|
|
22
|
-
# Configure SearchEngine synchronization for this
|
|
23
|
-
#
|
|
24
|
-
# - collection: defaults to the AR class tableized name (snake_case, plural)
|
|
25
|
-
# - on: one or many of :create, :update, :destroy (strings or symbols)
|
|
26
|
-
# - sync_strategy: :callbacks (default) or :postgres_outbox
|
|
36
|
+
# Configure SearchEngine synchronization for this model and its subclasses.
|
|
37
|
+
# SearchEngine models can register after this declaration.
|
|
27
38
|
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# @param collection [Symbol, String, nil]
|
|
34
|
-
# @param on [Array<Symbol,String>, Symbol, String, nil]
|
|
35
|
-
# @param sync_strategy [Symbol, String, nil]
|
|
36
|
-
# @return [Class] self (for macro chaining)
|
|
39
|
+
# @param collection [Symbol, String, nil] defaults to the tableized model name
|
|
40
|
+
# @param on [Array<Symbol,String>, Symbol, String, nil] one or more of create, update and destroy
|
|
41
|
+
# @param sync_strategy [Symbol, String, nil] callbacks (default) or postgres_outbox
|
|
42
|
+
# @return [Class] self
|
|
37
43
|
def search_engine_syncable(collection: nil, on: nil, sync_strategy: :callbacks)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
SearchEngine::ActiveRecordSyncable.__register_callbacks_for(self, cfg) if cfg[:sync_strategy] == :callbacks
|
|
51
|
-
|
|
44
|
+
previous = _se_syncable_config
|
|
45
|
+
config = {
|
|
46
|
+
logical: (collection || ActiveSupport::Inflector.tableize(name)).to_s.dup.freeze,
|
|
47
|
+
actions: SearchEngine::ActiveRecordSyncable.__normalize_actions(on).freeze,
|
|
48
|
+
sync_strategy: SearchEngine::ActiveRecordSyncable.__normalize_sync_strategy(sync_strategy),
|
|
49
|
+
timing: SearchEngine.config.syncable_callback_timing
|
|
50
|
+
}.freeze
|
|
51
|
+
self._se_syncable_config = config
|
|
52
|
+
if config[:sync_strategy] == :callbacks && config != previous
|
|
53
|
+
SearchEngine::ActiveRecordSyncable.__register_callbacks_for(self, config)
|
|
54
|
+
end
|
|
52
55
|
self
|
|
53
56
|
end
|
|
54
57
|
end
|
|
55
58
|
|
|
56
|
-
#
|
|
57
|
-
|
|
59
|
+
# @api private
|
|
60
|
+
def __se_syncable_create!
|
|
61
|
+
__se_syncable_upsert!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Upsert this record using the configured SearchEngine model.
|
|
58
65
|
# @return [void]
|
|
59
66
|
def __se_syncable_upsert!
|
|
60
|
-
|
|
61
|
-
se_klass = cfg[:se_klass]
|
|
62
|
-
unless se_klass
|
|
63
|
-
# Lazy-resolve the SearchEngine model to avoid boot-time ordering issues
|
|
64
|
-
begin
|
|
65
|
-
se_klass = SearchEngine.collection_for(cfg[:logical])
|
|
66
|
-
cfg[:se_klass] = se_klass if se_klass
|
|
67
|
-
rescue StandardError
|
|
68
|
-
se_klass = nil
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
unless se_klass
|
|
72
|
-
SearchEngine.config.logger&.warn(
|
|
73
|
-
"search_engine_syncable: no SearchEngine model registered for '#{cfg[:logical]}'; skip upsert"
|
|
74
|
-
)
|
|
75
|
-
return
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
se_klass.upsert(record: self)
|
|
79
|
-
rescue StandardError => error
|
|
80
|
-
SearchEngine.config.logger&.error("search_engine_syncable upsert failed: #{error}")
|
|
67
|
+
sync_search_engine_record
|
|
81
68
|
end
|
|
82
69
|
|
|
83
|
-
# Delete
|
|
84
|
-
# Uses the SearchEngine model's identity strategy when available.
|
|
70
|
+
# Delete the mapped document. Collections without a model use the ActiveRecord primary key.
|
|
85
71
|
# @return [void]
|
|
86
72
|
def __se_syncable_delete!
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
se_klass.compute_document_id(self)
|
|
93
|
-
else
|
|
94
|
-
respond_to?(:id) ? id.to_s : nil
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
if document_id.nil? || document_id.strip.empty?
|
|
98
|
-
SearchEngine.config.logger&.warn(
|
|
99
|
-
"search_engine_syncable: cannot delete without id for '#{logical}'"
|
|
100
|
-
)
|
|
73
|
+
logical = __se_syncable_logical
|
|
74
|
+
model = __se_syncable_model(logical)
|
|
75
|
+
document_id = model ? model.compute_document_id(self) : id.to_s
|
|
76
|
+
if document_id.blank?
|
|
77
|
+
SearchEngine.config.logger&.warn("search_engine_syncable: cannot delete without id for '#{logical}'")
|
|
101
78
|
return
|
|
102
79
|
end
|
|
103
80
|
|
|
@@ -108,123 +85,51 @@ module SearchEngine
|
|
|
108
85
|
SearchEngine.config.logger&.error("search_engine_syncable delete failed: #{error}")
|
|
109
86
|
end
|
|
110
87
|
|
|
111
|
-
# Return the associated SearchEngine record
|
|
112
|
-
#
|
|
113
|
-
# Resolves the SearchEngine model lazily when necessary and computes the
|
|
114
|
-
# document id via the model's `identify_by` strategy. Returns nil when the
|
|
115
|
-
# model mapping is unavailable or the id cannot be determined.
|
|
116
|
-
#
|
|
117
|
-
# @return [Object, nil] hydrated SearchEngine model instance or nil when not found
|
|
88
|
+
# Return the associated SearchEngine record using the current model and identity strategy.
|
|
89
|
+
# @return [Object, nil] hydrated SearchEngine model instance or nil when unavailable
|
|
118
90
|
def search_engine_record
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if se_klass.nil?
|
|
122
|
-
begin
|
|
123
|
-
logical = cfg[:logical] || self.class.name.to_s
|
|
124
|
-
se_klass = SearchEngine.collection_for(logical)
|
|
125
|
-
cfg[:se_klass] = se_klass if se_klass
|
|
126
|
-
rescue StandardError
|
|
127
|
-
return nil
|
|
128
|
-
end
|
|
129
|
-
end
|
|
91
|
+
model = __se_syncable_model
|
|
92
|
+
return unless model
|
|
130
93
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
# do not fall back to the ActiveRecord id on computation errors as it
|
|
136
|
-
# may point to a different document. Only fall back to AR id when
|
|
137
|
-
# identify_by is not defined at all.
|
|
138
|
-
if se_klass.instance_variable_defined?(:@identify_by_proc)
|
|
139
|
-
SearchEngine.config.logger&.warn(
|
|
140
|
-
"search_engine_syncable: identify_by failed to compute id for '#{cfg[:logical]}' (#{error.class})"
|
|
141
|
-
)
|
|
142
|
-
nil
|
|
143
|
-
else
|
|
144
|
-
respond_to?(:id) ? id.to_s : nil
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
return nil if doc_id.nil? || doc_id.to_s.strip.empty?
|
|
148
|
-
|
|
149
|
-
se_klass.find(doc_id)
|
|
150
|
-
rescue StandardError
|
|
94
|
+
document_id = model.compute_document_id(self)
|
|
95
|
+
model.find(document_id) if document_id.present?
|
|
96
|
+
rescue StandardError => error
|
|
97
|
+
SearchEngine.config.logger&.error("search_engine_syncable lookup failed: #{error}")
|
|
151
98
|
nil
|
|
152
99
|
end
|
|
153
100
|
|
|
154
101
|
# Map this ActiveRecord instance using the associated SearchEngine model and
|
|
155
102
|
# upsert it into the collection.
|
|
156
|
-
#
|
|
157
103
|
# @return [Integer] number of upserted documents (0 or 1)
|
|
158
104
|
def sync_search_engine_record
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if se_klass.nil?
|
|
162
|
-
begin
|
|
163
|
-
logical = cfg[:logical] || self.class.name.to_s
|
|
164
|
-
se_klass = SearchEngine.collection_for(logical)
|
|
165
|
-
cfg[:se_klass] = se_klass if se_klass
|
|
166
|
-
rescue StandardError => error
|
|
167
|
-
SearchEngine.config.logger&.warn(
|
|
168
|
-
"search_engine_syncable: cannot resolve model for sync (#{error.class})"
|
|
169
|
-
)
|
|
170
|
-
return 0
|
|
171
|
-
end
|
|
172
|
-
end
|
|
105
|
+
model = __se_syncable_model
|
|
106
|
+
return model.upsert(record: self) if model
|
|
173
107
|
|
|
174
|
-
|
|
108
|
+
SearchEngine.config.logger&.warn(
|
|
109
|
+
"search_engine_syncable: no SearchEngine model registered for '#{__se_syncable_logical}'; skip upsert"
|
|
110
|
+
)
|
|
111
|
+
0
|
|
175
112
|
rescue StandardError => error
|
|
176
113
|
SearchEngine.config.logger&.error("search_engine_syncable sync failed: #{error}")
|
|
177
114
|
0
|
|
178
115
|
end
|
|
179
116
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
# @api private
|
|
183
|
-
# @param ar_klass [Class]
|
|
184
|
-
# @param collection [String,Symbol,nil]
|
|
185
|
-
# @param actions [Array<String,Symbol>, String, Symbol, nil]
|
|
186
|
-
# @param sync_strategy [String, Symbol, nil]
|
|
187
|
-
# @return [Hash]
|
|
188
|
-
def __normalize_config_for(ar_klass, collection:, actions:, sync_strategy: :callbacks)
|
|
189
|
-
require 'active_support/inflector'
|
|
190
|
-
|
|
191
|
-
logical = (collection || ActiveSupport::Inflector.tableize(ar_klass.name)).to_s
|
|
192
|
-
normalized_actions = __normalize_actions(actions)
|
|
193
|
-
normalized_sync_strategy = __normalize_sync_strategy(sync_strategy)
|
|
117
|
+
private
|
|
194
118
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
rescue StandardError
|
|
199
|
-
nil
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
if se_klass.nil? && (normalized_actions.include?(:create) || normalized_actions.include?(:update))
|
|
203
|
-
SearchEngine.config.logger&.warn(
|
|
204
|
-
"search_engine_syncable: mapping for '#{logical}' not found at boot; will resolve lazily at runtime"
|
|
205
|
-
)
|
|
206
|
-
end
|
|
119
|
+
def __se_syncable_model(logical = __se_syncable_logical)
|
|
120
|
+
SearchEngine.collection_for(logical) { nil }
|
|
121
|
+
end
|
|
207
122
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
actions: normalized_actions,
|
|
211
|
-
sync_strategy: normalized_sync_strategy,
|
|
212
|
-
se_klass: se_klass
|
|
213
|
-
}
|
|
123
|
+
def __se_syncable_logical
|
|
124
|
+
_se_syncable_config ? _se_syncable_config.fetch(:logical) : self.class.name.to_s
|
|
214
125
|
end
|
|
215
126
|
|
|
127
|
+
module_function
|
|
128
|
+
|
|
216
129
|
# @api private
|
|
217
|
-
# @param actions [Array<String,Symbol>, String, Symbol, nil]
|
|
218
|
-
# @return [Array<Symbol>]
|
|
219
130
|
def __normalize_actions(actions)
|
|
220
|
-
allowed =
|
|
221
|
-
list =
|
|
222
|
-
if actions.nil?
|
|
223
|
-
allowed
|
|
224
|
-
else
|
|
225
|
-
Array(actions).map { |a| a.to_s.downcase.strip.to_sym }
|
|
226
|
-
end
|
|
227
|
-
|
|
131
|
+
allowed = CALLBACKS.keys
|
|
132
|
+
list = actions.nil? ? allowed : Array(actions).map { |action| action.to_s.downcase.strip.to_sym }
|
|
228
133
|
invalid = list - allowed
|
|
229
134
|
raise ArgumentError, "search_engine_syncable: actions must be within #{allowed.inspect}" unless invalid.empty?
|
|
230
135
|
|
|
@@ -232,8 +137,6 @@ module SearchEngine
|
|
|
232
137
|
end
|
|
233
138
|
|
|
234
139
|
# @api private
|
|
235
|
-
# @param strategy [String, Symbol, nil]
|
|
236
|
-
# @return [Symbol]
|
|
237
140
|
def __normalize_sync_strategy(strategy)
|
|
238
141
|
allowed = %i[callbacks postgres_outbox]
|
|
239
142
|
normalized = strategy.nil? ? :callbacks : strategy.to_s.downcase.strip.to_sym
|
|
@@ -245,37 +148,22 @@ module SearchEngine
|
|
|
245
148
|
normalized
|
|
246
149
|
end
|
|
247
150
|
|
|
248
|
-
# (no-op placeholder kept for backwards compatibility of method table in case of reloads)
|
|
249
|
-
|
|
250
151
|
# @api private
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
if timing == :after_commit
|
|
268
|
-
upsert_actions = actions & %i[create update]
|
|
269
|
-
ar_klass.after_commit :__se_syncable_upsert!, on: upsert_actions unless upsert_actions.empty?
|
|
270
|
-
ar_klass.after_destroy_commit :__se_syncable_delete! if actions.include?(:destroy)
|
|
271
|
-
else
|
|
272
|
-
ar_klass.after_create :__se_syncable_upsert! if actions.include?(:create)
|
|
273
|
-
ar_klass.after_update :__se_syncable_upsert! if actions.include?(:update)
|
|
274
|
-
ar_klass.after_destroy :__se_syncable_delete! if actions.include?(:destroy)
|
|
152
|
+
def __register_callbacks_for(ar_klass, config)
|
|
153
|
+
timing = config.fetch(:timing)
|
|
154
|
+
config.fetch(:actions).each do |action|
|
|
155
|
+
# An inherited callback can be narrowed or disabled by a subclass declaration.
|
|
156
|
+
enabled = lambda do
|
|
157
|
+
current = _se_syncable_config
|
|
158
|
+
current[:sync_strategy] == :callbacks && current[:timing] == timing && current[:actions].include?(action)
|
|
159
|
+
end
|
|
160
|
+
method = CALLBACKS.fetch(action)
|
|
161
|
+
if timing == :after_commit
|
|
162
|
+
ar_klass.after_commit method, on: action, if: enabled
|
|
163
|
+
else
|
|
164
|
+
ar_klass.public_send("after_#{action}", method, if: enabled)
|
|
165
|
+
end
|
|
275
166
|
end
|
|
276
|
-
|
|
277
|
-
ar_klass.instance_variable_set(:@__se_syncable_callbacks_installed__, true)
|
|
278
|
-
nil
|
|
279
167
|
end
|
|
280
168
|
end
|
|
281
169
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'search_engine/filters/sanitizer'
|
|
4
|
+
|
|
3
5
|
module SearchEngine
|
|
4
6
|
module AST
|
|
5
7
|
# Abstract base node for the predicate AST.
|
|
@@ -154,12 +156,7 @@ module SearchEngine
|
|
|
154
156
|
# @param field [String, Symbol]
|
|
155
157
|
# @return [String]
|
|
156
158
|
def validate_field!(field)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
str = field.to_s.strip
|
|
160
|
-
raise ArgumentError, 'field cannot be blank' if str.empty?
|
|
161
|
-
|
|
162
|
-
str
|
|
159
|
+
SearchEngine::Filters::Sanitizer.field_name(field, joined: true).dup.freeze
|
|
163
160
|
end
|
|
164
161
|
|
|
165
162
|
# Ensure values is a non-empty Array
|
|
@@ -112,8 +112,9 @@ module SearchEngine
|
|
|
112
112
|
|
|
113
113
|
def timeout_result(store, run_id, snapshot)
|
|
114
114
|
mark_non_terminal_failed(store, run_id, snapshot)
|
|
115
|
-
snapshot = store.snapshot(run_id: run_id)
|
|
116
|
-
current_result =
|
|
115
|
+
snapshot = store.snapshot(run_id: run_id)
|
|
116
|
+
current_result = result_for_snapshot(snapshot)
|
|
117
|
+
return finish_failure(snapshot, current_result) if invalid_snapshot_result?(current_result)
|
|
117
118
|
return finish_success(snapshot, current_result) if current_result[:status] == :ok
|
|
118
119
|
|
|
119
120
|
result = failed_result(
|
|
@@ -130,11 +131,12 @@ module SearchEngine
|
|
|
130
131
|
partitions = snapshot && snapshot[:partitions]
|
|
131
132
|
return unless partitions.is_a?(Hash)
|
|
132
133
|
|
|
134
|
+
writer = SearchEngine::IndexingRunStore.partition_writer(store)
|
|
133
135
|
partitions.each do |partition_key, entry|
|
|
134
136
|
status = entry[:status].to_s
|
|
135
137
|
next if %w[succeeded failed].include?(status)
|
|
136
138
|
|
|
137
|
-
|
|
139
|
+
writer.mark_failed(
|
|
138
140
|
run_id: run_id,
|
|
139
141
|
partition_key: partition_key,
|
|
140
142
|
error: 'partition did not finish before timeout'
|
|
@@ -24,6 +24,8 @@ module SearchEngine
|
|
|
24
24
|
instrument(:get, path, duration, cache_params, request_token: start)
|
|
25
25
|
log_success(:get, path, start, cache_params)
|
|
26
26
|
|
|
27
|
+
return yield(result) if block_given?
|
|
28
|
+
|
|
27
29
|
klass = begin
|
|
28
30
|
SearchEngine.collection_for(collection)
|
|
29
31
|
rescue ArgumentError
|
data/lib/search_engine/client.rb
CHANGED
|
@@ -29,12 +29,13 @@ module SearchEngine
|
|
|
29
29
|
# @param collection [String] collection name
|
|
30
30
|
# @param params [Hash] Typesense search parameters (q, query_by, etc.)
|
|
31
31
|
# @param url_opts [Hash] URL/common knobs (use_cache, cache_ttl)
|
|
32
|
+
# @yieldparam raw [Hash] optional result builder receives the response before hydration
|
|
32
33
|
# @return [SearchEngine::Result] Wrapped response with hydrated hits
|
|
33
34
|
# @raise [SearchEngine::Errors::InvalidParams, SearchEngine::Errors::*]
|
|
34
35
|
# @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/client`
|
|
35
36
|
# @see `https://typesense.org/docs/latest/api/documents.html#search-document`
|
|
36
|
-
def search(collection:, params:, url_opts: {})
|
|
37
|
-
services.fetch(:search).call(collection: collection, params: params, url_opts: url_opts)
|
|
37
|
+
def search(collection:, params:, url_opts: {}, &result_builder)
|
|
38
|
+
services.fetch(:search).call(collection: collection, params: params, url_opts: url_opts, &result_builder)
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
# Resolve a logical collection name that might be an alias to the physical collection name.
|
|
@@ -387,6 +388,11 @@ module SearchEngine
|
|
|
387
388
|
services.fetch(:search).multi(searches: searches, url_opts: url_opts)
|
|
388
389
|
end
|
|
389
390
|
|
|
391
|
+
# Default URL-level search options for this client's configuration.
|
|
392
|
+
def search_url_options
|
|
393
|
+
ClientOptions.url_options_from_config(config)
|
|
394
|
+
end
|
|
395
|
+
|
|
390
396
|
# Clear the Typesense server-side search cache.
|
|
391
397
|
#
|
|
392
398
|
# @return [Hash] response payload from Typesense (symbolized keys)
|
|
@@ -692,7 +698,7 @@ module SearchEngine
|
|
|
692
698
|
end
|
|
693
699
|
|
|
694
700
|
def derive_cache_opts(url_opts)
|
|
695
|
-
merged =
|
|
701
|
+
merged = search_url_options
|
|
696
702
|
merged[:use_cache] = url_opts[:use_cache] if url_opts.key?(:use_cache) && !url_opts[:use_cache].nil?
|
|
697
703
|
merged[:cache_ttl] = Integer(url_opts[:cache_ttl]) if url_opts.key?(:cache_ttl)
|
|
698
704
|
merged
|