search-engine-for-typesense 30.1.8.20 → 30.1.8.23
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 +62 -0
- data/README.md +184 -1
- data/app/search_engine/search_engine/index_partition_job.rb +40 -5
- data/app/search_engine/search_engine/postgres_outbox/drain_job.rb +4 -3
- data/lib/generators/search_engine/install/templates/initializer.rb.tt +22 -1
- data/lib/search_engine/async_partition_coordinator.rb +7 -1
- data/lib/search_engine/base/creation.rb +40 -12
- data/lib/search_engine/cascade.rb +6 -96
- data/lib/search_engine/cli.rb +26 -6
- data/lib/search_engine/config/validators.rb +21 -0
- data/lib/search_engine/config.rb +25 -2
- data/lib/search_engine/dispatcher.rb +18 -4
- data/lib/search_engine/errors.rb +32 -2
- data/lib/search_engine/indexer/import_dispatcher.rb +18 -4
- data/lib/search_engine/indexer/import_response_parser.rb +109 -61
- data/lib/search_engine/indexing_run.rb +1 -0
- data/lib/search_engine/indexing_run_store/rails_cache.rb +37 -1
- data/lib/search_engine/indexing_run_store.rb +1 -1
- data/lib/search_engine/observability.rb +64 -1
- data/lib/search_engine/postgres_outbox/drainer.rb +127 -55
- data/lib/search_engine/postgres_outbox/event.rb +3 -1
- data/lib/search_engine/postgres_outbox/event_processor.rb +79 -13
- data/lib/search_engine/postgres_outbox/migration_helpers.rb +47 -8
- data/lib/search_engine/postgres_outbox/processor_result.rb +92 -3
- data/lib/search_engine/postgres_outbox/repository.rb +942 -238
- data/lib/search_engine/schema.rb +246 -32
- data/lib/search_engine/version.rb +1 -1
- data/lib/tasks/search_engine.rake +44 -11
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd7e450cd267dab4d557451ce255f20707b07d2a97cf42b143dfdc601561668f
|
|
4
|
+
data.tar.gz: 29e7f4f858407039f8e477a8d0f0fc1b9985e3922214bae03def126065e31106
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ab3eda607afe100090554542a0c74a89d1418da5bf8c0034c35fbd4b26ae1c2b6dfbeb8789d4a16c313b34179f64b0c031a61bb463133c14a51087e5f1fe83a
|
|
7
|
+
data.tar.gz: a3a21521da9b613004e0883142d6c9ec63e3711d573be90b28db7eeb8dfcf391872876d182f2bd2c21e08d56963a2e746415bd6c4cc0eddf4166bf67b2cffc70
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 30.1.8.23
|
|
4
|
+
|
|
5
|
+
- Run retained-physical alias rollbacks through the same cutover guard. Rollback accepts explicit destination
|
|
6
|
+
and expected-current compare-and-swap targets, validates older retained physicals, and makes legacy retries a
|
|
7
|
+
conservative no-op instead of toggling the alias forward again.
|
|
8
|
+
- Make row-trigger notification payloads constant per source table and collection so PostgreSQL folds repeated
|
|
9
|
+
notifications from one transaction into one low-latency wakeup. Durable event identity remains in the outbox
|
|
10
|
+
table, which continues to be the source of truth.
|
|
11
|
+
- Add `replace_search_engine_outbox_trigger_function` for forward migrations that only need to refresh generated
|
|
12
|
+
function bodies, without dropping triggers or taking avoidable trigger DDL locks on source tables.
|
|
13
|
+
- Renew target-scoped delivery leases before every dependency-ordered collection group. Deliveries whose fenced
|
|
14
|
+
lease cannot be renewed are never passed to a processor, so slow earlier groups cannot consume the lease budget
|
|
15
|
+
of later groups in the same claim.
|
|
16
|
+
- Add opt-in `postgres_outbox.clear_cache_after_write` invalidation. Enabled hosts clear Typesense's server-side
|
|
17
|
+
search cache before acknowledging each processed collection group; a clear failure keeps the group retryable.
|
|
18
|
+
|
|
19
|
+
No database migration is added by this release. Existing PostgreSQL outbox delivery/slot migrations remain
|
|
20
|
+
compatible.
|
|
21
|
+
|
|
22
|
+
## 30.1.8.22
|
|
23
|
+
|
|
24
|
+
- Add `schema.around_rebuild`, an exact-once guard around full physical create/index/alias-swap/retention
|
|
25
|
+
lifecycles. In-place schema updates remain outside the guard, while guard and indexing failures propagate
|
|
26
|
+
without leaking interrupted physical collections.
|
|
27
|
+
- Add PostgreSQL delivery-target cutover locks. Full rebuilds can pause new claims, wait for acknowledged
|
|
28
|
+
processing deliveries, and exclude cooperating direct Typesense writers with one deterministic target key.
|
|
29
|
+
Locks are session-safe, timeout-bounded, and automatically released when the database session dies.
|
|
30
|
+
- Gate every delivery claim before target row work. Timed-out leases are reclaimed only for the bounded rows
|
|
31
|
+
actually selected; unselected rows remain `processing`, and stale older operations are settled before newer
|
|
32
|
+
siblings so delete/recreation ordering cannot reverse.
|
|
33
|
+
- Pin built-in outbox upserts and deletes to one resolved physical collection for the claimed operation, so a
|
|
34
|
+
delayed stale HTTP request cannot follow a logical alias onto a replacement collection after cutover.
|
|
35
|
+
- Reject standalone `reset_stale_processing!` in delivery mode because reset without same-transaction reclaim
|
|
36
|
+
can create false quiescence. Single-target event mode is unchanged.
|
|
37
|
+
- Make forced cascade rebuild failures propagate instead of downgrading to live partition imports.
|
|
38
|
+
- Mark `search_engine:index:rebuild` and `search_engine:index:rebuild_partition` as live-maintenance paths. When
|
|
39
|
+
`schema.around_rebuild` is configured, they now require explicit `ALLOW_LIVE_INDEX_MAINTENANCE=true` after
|
|
40
|
+
operators pause outbox consumers and direct writers. `search_engine:schema:apply` accepts
|
|
41
|
+
`FORCE_REBUILD=true` as the explicit guarded full-data rebuild path.
|
|
42
|
+
|
|
43
|
+
No database migration is added by this release. Existing PostgreSQL outbox delivery/slot migrations remain
|
|
44
|
+
compatible.
|
|
45
|
+
|
|
46
|
+
## 30.1.8.21
|
|
47
|
+
|
|
48
|
+
- Bound PostgreSQL outbox claim ranking and harden delivery coalescing with parent-first locking, fenced
|
|
49
|
+
delivery leases, lock-order-safe stale resets, and per-event processing failure isolation.
|
|
50
|
+
- Add explicit, audited, idempotent delivery-target retirement with a required dry-run/apply decision. Target
|
|
51
|
+
retirement never infers intent from configuration removal and does not alter drain slots.
|
|
52
|
+
- Add strict ordered Typesense import response parsing and `upsert_bulk(..., on_failure: :return)` for stable
|
|
53
|
+
row-level outcomes. Malformed, ambiguous, short, and long responses now fail closed.
|
|
54
|
+
- Remove raw Typesense `response` data from bulk upsert result/error metadata because it can contain submitted
|
|
55
|
+
documents. Callers that used `result[:response]` must migrate to `row_results` and aggregate counters.
|
|
56
|
+
- Preserve safe structured error metadata while recursively redacting secrets and payload-bearing fields.
|
|
57
|
+
- Reject unsupported dispatch configuration/overrides instead of silently falling back to inline execution.
|
|
58
|
+
- Make partial async partition imports retryable and observable. Custom partition run stores must add
|
|
59
|
+
`record_attempt(run_id:, partition_key:, summary:, error:)` as a non-terminal transition.
|
|
60
|
+
|
|
61
|
+
No database migration is added by this release. Existing PostgreSQL outbox delivery/slot migrations remain
|
|
62
|
+
compatible.
|
data/README.md
CHANGED
|
@@ -117,6 +117,11 @@ SearchEngine::Product.upsert_bulk(records: Product.limit(2))
|
|
|
117
117
|
# Bulk upsert mapped payloads
|
|
118
118
|
SearchEngine::Product.upsert_bulk(data: [mapped])
|
|
119
119
|
|
|
120
|
+
# Inspect row-level failures without repeating the import request
|
|
121
|
+
result = SearchEngine::Product.upsert_bulk(data: [mapped], on_failure: :return)
|
|
122
|
+
failed_rows = result[:row_results].reject { |row| row[:success] }
|
|
123
|
+
failed_rows.first # => { index: 0, success: false, status: 404, error: "..." }
|
|
124
|
+
|
|
120
125
|
# Geo search
|
|
121
126
|
class SearchEngine::Venue < SearchEngine::Base
|
|
122
127
|
collection :venues
|
|
@@ -148,6 +153,15 @@ result = SearchEngine::Venue.all.order_geo(:location, from: { lat: 54.69, lng: 2
|
|
|
148
153
|
result.hits.first.geo_distance_meters # => { "location" => 1234 }
|
|
149
154
|
```
|
|
150
155
|
|
|
156
|
+
`upsert_bulk` defaults to `on_failure: :raise`. Use the exact symbol `:return` when the caller needs to
|
|
157
|
+
handle valid Typesense row failures itself. Malformed responses and response/document count mismatches always
|
|
158
|
+
raise in either mode; the gem never guesses which submitted document a missing response row belongs to.
|
|
159
|
+
|
|
160
|
+
Since `30.1.8.21`, bulk results intentionally do not expose the raw Typesense `response` because import
|
|
161
|
+
responses may contain submitted documents. Use the ordered, frozen `row_results` entries (`index`, `success`,
|
|
162
|
+
`status`, `error`) and aggregate counters instead. This is a safety-related result-shape change for callers
|
|
163
|
+
that previously read `result[:response]`.
|
|
164
|
+
|
|
151
165
|
## Documentation
|
|
152
166
|
|
|
153
167
|
See the [Docs](https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/index)
|
|
@@ -188,6 +202,124 @@ Use a shared `Rails.cache` backend, or provide `c.indexer.partition_run_store`,
|
|
|
188
202
|
the parent indexing process can see the same run metadata. Size the queue carefully: worker concurrency
|
|
189
203
|
multiplies with any per-partition `max_parallel` setting.
|
|
190
204
|
|
|
205
|
+
A custom partition run store must implement:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
create_run(run_id:, collection:, collection_class_name:, into:, partitions:, ttl_s:)
|
|
209
|
+
mark_started(run_id:, partition_key:, job_id: nil)
|
|
210
|
+
record_attempt(run_id:, partition_key:, summary:, error:)
|
|
211
|
+
mark_succeeded(run_id:, partition_key:, summary:)
|
|
212
|
+
mark_failed(run_id:, partition_key:, error:)
|
|
213
|
+
snapshot(run_id:)
|
|
214
|
+
expire(run_id:)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`record_attempt` is non-terminal: it must persist that partition's partial summary and representative error
|
|
218
|
+
while leaving the partition `running`, without changing sibling partitions. Row-level import failures raise
|
|
219
|
+
from `IndexPartitionJob`, so ActiveJob retries the partition. Replaying successful upserts is expected and
|
|
220
|
+
safe. Only retry exhaustion terminalizes the partition as failed.
|
|
221
|
+
|
|
222
|
+
Only `:inline` and `:active_job` (or their exact String equivalents) are supported for
|
|
223
|
+
`c.indexer.dispatch` and `c.indexer.partition_execution`. Unknown values such as `:sidekiq` fail configuration
|
|
224
|
+
validation instead of silently running inline. Explicit `:active_job` dispatch also fails if ActiveJob is not
|
|
225
|
+
available.
|
|
226
|
+
|
|
227
|
+
## Guarded blue/green cutovers
|
|
228
|
+
|
|
229
|
+
`SearchEngine::Schema.apply!` can run an optional guard around the complete full-rebuild lifecycle: physical
|
|
230
|
+
collection creation, indexing, alias swap, and retention. `SearchEngine::Schema.rollback` uses the same guard
|
|
231
|
+
around target resolution, validation, and its alias swap. In-place schema updates intentionally do not invoke
|
|
232
|
+
the guard. The guard must yield exactly once; not yielding or yielding repeatedly raises `ArgumentError`.
|
|
233
|
+
Guard errors and indexing errors propagate, and an interrupted pre-swap physical collection is still cleaned
|
|
234
|
+
up.
|
|
235
|
+
|
|
236
|
+
In delivery-target outbox deployments, use the guard to stop new target claims, wait for already-claimed
|
|
237
|
+
deliveries to acknowledge, and prevent cooperating direct writers from crossing the alias swap:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
# config/initializers/search_engine.rb
|
|
241
|
+
typesense_target_key = ENV.fetch("TYPESENSE_DELIVERY_TARGET_KEY")
|
|
242
|
+
|
|
243
|
+
SearchEngine.configure do |c|
|
|
244
|
+
c.schema.around_rebuild = lambda do |collection:, &rebuild|
|
|
245
|
+
Rails.logger.info("Guarding Typesense rebuild for #{collection} on #{typesense_target_key}")
|
|
246
|
+
SearchEngine::PostgresOutbox::Repository.new.with_delivery_target_claims_paused(
|
|
247
|
+
target_key: typesense_target_key,
|
|
248
|
+
timeout_s: SearchEngine.config.postgres_outbox.processing_timeout_s + 60,
|
|
249
|
+
poll_interval_s: 0.1,
|
|
250
|
+
&rebuild
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The target key is the exact delivery destination key, not the logical collection name. Claim transactions
|
|
257
|
+
automatically take the matching transaction-scoped shared PostgreSQL advisory lock. The rebuild guard takes
|
|
258
|
+
an exclusive session lock, then waits until that target has no `processing` deliveries. It deliberately does
|
|
259
|
+
not reset timed-out leases: lease age cannot prove that an old worker's external Typesense request has
|
|
260
|
+
stopped. A stuck processing delivery therefore times out the rebuild; recover it operationally before
|
|
261
|
+
retrying. PostgreSQL releases the session lock if the owning connection or process dies.
|
|
262
|
+
Set the guard timeout above `processing_timeout_s` with enough margin for the slowest legitimate external
|
|
263
|
+
write and scheduling delay; a shorter fixed timeout causes avoidable rebuild failures.
|
|
264
|
+
|
|
265
|
+
Every direct Typesense writer outside the delivery drainer must cooperate with the same exact target key:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
SearchEngine::PostgresOutbox::Repository.new.with_delivery_target_writes_allowed(
|
|
269
|
+
target_key: typesense_target_key,
|
|
270
|
+
timeout_s: 5
|
|
271
|
+
) do
|
|
272
|
+
SearchEngine::Product.upsert(record: product)
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Claimed delivery requests must resolve the logical alias once and write to that pinned physical collection until
|
|
277
|
+
the delivery is acknowledged. The built-in `EventProcessor` does this for both upserts and deletes. Custom
|
|
278
|
+
collection processors must follow the same rule: a stale worker whose HTTP request completes after lease reclaim
|
|
279
|
+
must only be able to mutate the retired physical collection, never whichever physical collection the logical alias
|
|
280
|
+
points to after a later cutover.
|
|
281
|
+
|
|
282
|
+
Rollback is conservative and retry-safe. The backward-compatible form rolls back one generation only when the
|
|
283
|
+
alias points at the newest retained physical; once it no longer does, repeating the call returns
|
|
284
|
+
`action: :already_rolled_back` without another swap. When newer orphaned physicals exist, or when an operator
|
|
285
|
+
needs an auditable destination, pass the retained target explicitly. Add `expected_current` for compare-and-swap
|
|
286
|
+
protection:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
SearchEngine::Schema.rollback(
|
|
290
|
+
SearchEngine::Product,
|
|
291
|
+
to: "products_20250101_000000_001",
|
|
292
|
+
expected_current: "products_20250102_000000_001"
|
|
293
|
+
)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The equivalent task remains backward compatible and also accepts both safety arguments:
|
|
297
|
+
|
|
298
|
+
```sh
|
|
299
|
+
rails 'search_engine:schema:rollback[products]'
|
|
300
|
+
rails 'search_engine:schema:rollback[products,products_20250101_000000_001,products_20250102_000000_001]'
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
An explicit destination must be a retained physical older than the current/expected source. Retrying after the
|
|
304
|
+
alias already reached that destination is a successful no-op. Any other expected-current mismatch raises
|
|
305
|
+
`SearchEngine::Schema::RollbackConflict` without changing the alias.
|
|
306
|
+
|
|
307
|
+
This protocol only gates delivery-target claims. Legacy single-target event claims have no target identity and
|
|
308
|
+
must be stopped before a rebuild. Likewise, an unguarded direct writer can still cross the swap. In
|
|
309
|
+
delivery-target mode, `reset_stale_processing!` rejects standalone use because reset without same-transaction
|
|
310
|
+
reclaim creates a false-quiescence gap. Ordinary claims directly reclaim only the bounded timed-out rows they
|
|
311
|
+
actually select; unselected stale rows remain `processing`, so a cutover continues to fail closed.
|
|
312
|
+
|
|
313
|
+
Forced cascade rebuilds use the guarded blue/green path and propagate any rebuild/guard failure. They never
|
|
314
|
+
downgrade to a live partition import. The maintenance tasks `search_engine:index:rebuild` and
|
|
315
|
+
`search_engine:index:rebuild_partition` are intentionally different: they write directly into the resolved
|
|
316
|
+
live collection and do not invoke `schema.around_rebuild`. When a guard is configured, these tasks refuse to
|
|
317
|
+
run unless `ALLOW_LIVE_INDEX_MAINTENANCE=true` is explicitly set. Pause all outbox consumers and direct writers
|
|
318
|
+
before using that override. For a guarded forced blue/green data rebuild, run
|
|
319
|
+
`FORCE_REBUILD=true rails 'search_engine:schema:apply[collection]'`. Without `FORCE_REBUILD`, schema apply may
|
|
320
|
+
complete as an in-place schema update and intentionally skip document reindexing. Hosts with no configured
|
|
321
|
+
guard retain the legacy live-task behavior.
|
|
322
|
+
|
|
191
323
|
## PostgreSQL outbox sync
|
|
192
324
|
|
|
193
325
|
Rails callbacks are convenient for ordinary `create`, `update`, and `destroy` flows, but they do not see
|
|
@@ -199,7 +331,9 @@ The flow is:
|
|
|
199
331
|
|
|
200
332
|
1. A row-level PostgreSQL trigger writes a durable outbox row in the same transaction as the source table
|
|
201
333
|
change.
|
|
202
|
-
2. The trigger calls `pg_notify` as a low-latency nudge after commit.
|
|
334
|
+
2. The trigger calls `pg_notify` as a low-latency nudge after commit. Its payload is constant for a given
|
|
335
|
+
source table and collection, so PostgreSQL can fold repeated row-level notifications from one transaction
|
|
336
|
+
into a single wakeup.
|
|
203
337
|
3. A host-managed listener receives notifications, or falls back to polling, and enqueues
|
|
204
338
|
`SearchEngine::PostgresOutbox::DrainJob`.
|
|
205
339
|
4. The drainer claims pending rows, coalesces older rows for the same collection/document pair, orders
|
|
@@ -230,6 +364,7 @@ SearchEngine.configure do |c|
|
|
|
230
364
|
c.postgres_outbox.drain_target_parallelism = 1
|
|
231
365
|
c.postgres_outbox.drain_job_max_batches = 1
|
|
232
366
|
c.postgres_outbox.drain_job_max_runtime_s = nil
|
|
367
|
+
c.postgres_outbox.clear_cache_after_write = false
|
|
233
368
|
c.postgres_outbox.poll_interval_s = 5
|
|
234
369
|
c.postgres_outbox.retention_s = 7.days.to_i
|
|
235
370
|
|
|
@@ -246,10 +381,28 @@ SearchEngine.configure do |c|
|
|
|
246
381
|
end
|
|
247
382
|
```
|
|
248
383
|
|
|
384
|
+
When an upgrade changes only generated trigger-function behavior, migrations can call
|
|
385
|
+
`replace_search_engine_outbox_trigger_function` with the same arguments used to create the trigger. It runs
|
|
386
|
+
`CREATE OR REPLACE FUNCTION` without dropping and reattaching the existing trigger, avoiding unnecessary
|
|
387
|
+
table-level trigger DDL locks. The trigger must already exist.
|
|
388
|
+
|
|
249
389
|
`batch_size` is the global fallback for all collections. Use `batch_sizes` when some collections are much
|
|
250
390
|
lighter or heavier than others. Omitted drain limits use the per-collection values; explicit `limit:`
|
|
251
391
|
arguments still override the map and use one global cap for that drain.
|
|
252
392
|
|
|
393
|
+
When searches set `use_cache`, enable `clear_cache_after_write` if incremental
|
|
394
|
+
writes must be visible before their outbox rows are acknowledged. The drainer
|
|
395
|
+
clears Typesense's server-side search cache once after each processed
|
|
396
|
+
collection group and before database acknowledgement. If cache clearing fails,
|
|
397
|
+
the group remains retryable and its idempotent document writes are replayed.
|
|
398
|
+
The option defaults to `false` so hosts that do not use server-side search
|
|
399
|
+
caching do not add an unnecessary API call.
|
|
400
|
+
|
|
401
|
+
In delivery-target mode, a claim can contain several dependency-ordered collection groups. The drainer renews
|
|
402
|
+
all unstarted fenced leases before each group and removes any delivery it no longer owns before calling the
|
|
403
|
+
processor. Hosts must still size each individual collection group so its worst-case Typesense retry sequence
|
|
404
|
+
fits below `processing_timeout_s`; renewal protects later groups, not an unbounded current group.
|
|
405
|
+
|
|
253
406
|
Generate and edit the migrations:
|
|
254
407
|
|
|
255
408
|
```bash
|
|
@@ -341,6 +494,36 @@ hosts add the optional drain slot table.
|
|
|
341
494
|
Processors still receive event objects and return event IDs; the parent event status is refreshed from the
|
|
342
495
|
aggregate delivery states.
|
|
343
496
|
|
|
497
|
+
### Retiring a delivery target
|
|
498
|
+
|
|
499
|
+
Removing a target from `delivery_targets` does not infer permission to discard its persisted backlog. Remove
|
|
500
|
+
the target from configuration first, keep process configuration stable, then explicitly dry-run and apply
|
|
501
|
+
retirement:
|
|
502
|
+
|
|
503
|
+
```ruby
|
|
504
|
+
repository = SearchEngine::PostgresOutbox::Repository.new
|
|
505
|
+
|
|
506
|
+
preview = repository.retire_delivery_target!(
|
|
507
|
+
target_key: "mirror_a",
|
|
508
|
+
dry_run: true,
|
|
509
|
+
reason: "mirror_a was decommissioned"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
result = repository.retire_delivery_target!(
|
|
513
|
+
target_key: "mirror_a",
|
|
514
|
+
dry_run: false,
|
|
515
|
+
reason: "mirror_a was decommissioned",
|
|
516
|
+
operator: "deploy-2026-07-11"
|
|
517
|
+
)
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Both calls reject a target that is still configured. Apply supersedes only that exact target's `pending`,
|
|
521
|
+
`processing`, and `failed` deliveries, clears leases, stores a complete JSON audit record in `last_error`, and
|
|
522
|
+
refreshes parent event status under parent-first locks. It is idempotent; a second apply reports zero rows.
|
|
523
|
+
Drain slots are scheduler bookkeeping and are deliberately not changed. Configuration is rechecked directly
|
|
524
|
+
before each database mutation, but Ruby configuration and PostgreSQL cannot share an atomic lock, so do not
|
|
525
|
+
change delivery-target configuration concurrently with retirement.
|
|
526
|
+
|
|
344
527
|
Pair triggered source models with `sync_strategy: :postgres_outbox` so Active Record callbacks do not also
|
|
345
528
|
write to Typesense for the same changes:
|
|
346
529
|
|
|
@@ -19,6 +19,7 @@ module SearchEngine
|
|
|
19
19
|
# Handle transient errors with exponential backoff based on Indexer config.
|
|
20
20
|
rescue_from(SearchEngine::Errors::Timeout) { |error| retry_if_possible(error) }
|
|
21
21
|
rescue_from(SearchEngine::Errors::Connection) { |error| retry_if_possible(error) }
|
|
22
|
+
rescue_from(SearchEngine::Errors::PartitionImportFailed) { |error| retry_if_possible(error) }
|
|
22
23
|
rescue_from(SearchEngine::Errors::Api) do |error|
|
|
23
24
|
if SearchEngine::Indexer::RetryPolicy.transient_status?(error.status.to_i)
|
|
24
25
|
retry_if_possible(error)
|
|
@@ -52,6 +53,7 @@ module SearchEngine
|
|
|
52
53
|
SearchEngine::Instrumentation.with_context(dispatch_mode: :active_job, job_id: job_id) do
|
|
53
54
|
summary = SearchEngine::Indexer.rebuild_partition!(klass, partition: partition, into: into)
|
|
54
55
|
end
|
|
56
|
+
raise_for_failed_summary!(summary, run_store, run_id, partition_key)
|
|
55
57
|
duration = (monotonic_ms - started).round(1)
|
|
56
58
|
run_store&.mark_succeeded(run_id: run_id, partition_key: partition_key, summary: summary)
|
|
57
59
|
|
|
@@ -64,8 +66,12 @@ module SearchEngine
|
|
|
64
66
|
nil
|
|
65
67
|
rescue SearchEngine::IndexingRunStore::StaleRun => error
|
|
66
68
|
safe_payload = payload || error_payload(error)
|
|
67
|
-
instrument_stale_run(
|
|
68
|
-
|
|
69
|
+
instrument_stale_run(
|
|
70
|
+
error,
|
|
71
|
+
payload: safe_payload.merge(metadata: metadata || {}),
|
|
72
|
+
run_id: run_id,
|
|
73
|
+
partition_key: partition_key
|
|
74
|
+
)
|
|
69
75
|
nil
|
|
70
76
|
rescue StandardError => error
|
|
71
77
|
safe_payload = payload || error_payload(error)
|
|
@@ -75,6 +81,12 @@ module SearchEngine
|
|
|
75
81
|
raise
|
|
76
82
|
end
|
|
77
83
|
|
|
84
|
+
if error.is_a?(SearchEngine::Errors::PartitionImportFailed)
|
|
85
|
+
mark_failed_for_run(run_store, run_id, partition_key, error, payload: safe_payload) if run_id && run_store
|
|
86
|
+
instrument_error(error, payload: safe_payload)
|
|
87
|
+
raise
|
|
88
|
+
end
|
|
89
|
+
|
|
78
90
|
if run_id && run_store
|
|
79
91
|
mark_failed_for_run(run_store, run_id, partition_key, error, payload: safe_payload)
|
|
80
92
|
instrument_error(error, payload: safe_payload)
|
|
@@ -131,7 +143,8 @@ module SearchEngine
|
|
|
131
143
|
end
|
|
132
144
|
|
|
133
145
|
def retryable_error?(error)
|
|
134
|
-
transient_error?(error)
|
|
146
|
+
(transient_error?(error) || error.is_a?(SearchEngine::Errors::PartitionImportFailed)) &&
|
|
147
|
+
executions.to_i < retry_policy.attempts
|
|
135
148
|
end
|
|
136
149
|
|
|
137
150
|
def transient_error?(error)
|
|
@@ -142,6 +155,28 @@ module SearchEngine
|
|
|
142
155
|
SearchEngine::Indexer::RetryPolicy.transient_status?(error.status.to_i)
|
|
143
156
|
end
|
|
144
157
|
|
|
158
|
+
def summary_failed?(summary)
|
|
159
|
+
value = if summary.respond_to?(:failed_total)
|
|
160
|
+
summary.failed_total
|
|
161
|
+
elsif summary.is_a?(Hash)
|
|
162
|
+
summary[:failed_total] || summary['failed_total']
|
|
163
|
+
end
|
|
164
|
+
value.to_i.positive?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def raise_for_failed_summary!(summary, run_store, run_id, partition_key)
|
|
168
|
+
return unless summary_failed?(summary)
|
|
169
|
+
|
|
170
|
+
error = SearchEngine::Errors::PartitionImportFailed.new(summary)
|
|
171
|
+
run_store&.record_attempt(
|
|
172
|
+
run_id: run_id,
|
|
173
|
+
partition_key: partition_key,
|
|
174
|
+
summary: summary,
|
|
175
|
+
error: error
|
|
176
|
+
)
|
|
177
|
+
raise error
|
|
178
|
+
end
|
|
179
|
+
|
|
145
180
|
def error_payload(error)
|
|
146
181
|
{
|
|
147
182
|
collection: arguments_dig_collection,
|
|
@@ -174,8 +209,8 @@ module SearchEngine
|
|
|
174
209
|
|
|
175
210
|
def mark_failed_for_run(run_store, run_id, partition_key, error, payload:)
|
|
176
211
|
run_store.mark_failed(run_id: run_id, partition_key: partition_key, error: error)
|
|
177
|
-
rescue SearchEngine::IndexingRunStore::StaleRun =>
|
|
178
|
-
instrument_stale_run(
|
|
212
|
+
rescue SearchEngine::IndexingRunStore::StaleRun => error
|
|
213
|
+
instrument_stale_run(error, payload: payload, run_id: run_id, partition_key: partition_key)
|
|
179
214
|
end
|
|
180
215
|
|
|
181
216
|
def instrument_event(event, payload)
|
|
@@ -104,9 +104,9 @@ module SearchEngine
|
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
def drainer_for(target_key)
|
|
107
|
-
return SearchEngine::PostgresOutbox::Drainer.new if target_key.nil?
|
|
107
|
+
return SearchEngine::PostgresOutbox::Drainer.new(worker_id: worker_id) if target_key.nil?
|
|
108
108
|
|
|
109
|
-
SearchEngine::PostgresOutbox::Drainer.new(target_key: target_key)
|
|
109
|
+
SearchEngine::PostgresOutbox::Drainer.new(target_key: target_key, worker_id: worker_id)
|
|
110
110
|
end
|
|
111
111
|
|
|
112
112
|
def enqueue_continuation(limit:, target_key:)
|
|
@@ -163,6 +163,7 @@ module SearchEngine
|
|
|
163
163
|
superseded: 0,
|
|
164
164
|
retryable: 0,
|
|
165
165
|
failed: 0,
|
|
166
|
+
stale: 0,
|
|
166
167
|
collections: [],
|
|
167
168
|
target_key: target_key,
|
|
168
169
|
drain_slot: drain_slot
|
|
@@ -174,7 +175,7 @@ module SearchEngine
|
|
|
174
175
|
end
|
|
175
176
|
|
|
176
177
|
def merge_batch_summary!(summary, batch_summary)
|
|
177
|
-
%i[claimed processed superseded retryable failed].each do |key|
|
|
178
|
+
%i[claimed processed superseded retryable failed stale].each do |key|
|
|
178
179
|
summary[key] += batch_summary[key].to_i
|
|
179
180
|
end
|
|
180
181
|
summary[:collections] |= Array(batch_summary[:collections])
|
|
@@ -160,6 +160,24 @@ SearchEngine.configure do |c|
|
|
|
160
160
|
# Retention: how many previous physical collections to keep after swap. Default: 0
|
|
161
161
|
# c.schema.retention.keep_last = 0
|
|
162
162
|
|
|
163
|
+
# Optional guard around full physical create/index/alias-swap/retention rebuilds and alias rollbacks.
|
|
164
|
+
# Default: nil. In-place schema updates do not invoke it. The callable must yield exactly once.
|
|
165
|
+
# Delivery-target hosts can use Repository#with_delivery_target_claims_paused here; every direct
|
|
166
|
+
# Typesense writer must also use Repository#with_delivery_target_writes_allowed with the same target key.
|
|
167
|
+
# c.schema.around_rebuild = lambda do |collection:, &rebuild|
|
|
168
|
+
# target_key = ENV.fetch("TYPESENSE_DELIVERY_TARGET_KEY")
|
|
169
|
+
# SearchEngine::PostgresOutbox::Repository.new.with_delivery_target_claims_paused(
|
|
170
|
+
# target_key: target_key,
|
|
171
|
+
# timeout_s: SearchEngine.config.postgres_outbox.processing_timeout_s + 60,
|
|
172
|
+
# &rebuild
|
|
173
|
+
# )
|
|
174
|
+
# end
|
|
175
|
+
|
|
176
|
+
# When searches use Typesense's server-side cache, opt in to clearing it
|
|
177
|
+
# after each successfully processed outbox collection group and before the
|
|
178
|
+
# group is acknowledged. Default: false.
|
|
179
|
+
# c.postgres_outbox.clear_cache_after_write = false
|
|
180
|
+
|
|
163
181
|
# --- Indexer -------------------------------------------------------------
|
|
164
182
|
# See: https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/indexer
|
|
165
183
|
|
|
@@ -175,7 +193,8 @@ SearchEngine.configure do |c|
|
|
|
175
193
|
# Gzip JSONL payloads during import. Default: false
|
|
176
194
|
# c.indexer.gzip = false
|
|
177
195
|
|
|
178
|
-
# Dispatch mode for import jobs (:active_job or :inline). Default depends on ActiveJob presence
|
|
196
|
+
# Dispatch mode for import jobs (:active_job or :inline). Default depends on ActiveJob presence.
|
|
197
|
+
# Unsupported values fail configuration validation; :active_job requires ActiveJob.
|
|
179
198
|
# c.indexer.dispatch = :active_job
|
|
180
199
|
|
|
181
200
|
# Queue name for ActiveJob dispatch. Default: 'search_index'
|
|
@@ -196,6 +215,8 @@ SearchEngine.configure do |c|
|
|
|
196
215
|
|
|
197
216
|
# Operational metadata for async partition runs. Defaults shown.
|
|
198
217
|
# Use shared Rails.cache or provide a custom store visible to workers and parent.
|
|
218
|
+
# Custom stores must implement the IndexingRunStore contract, including the
|
|
219
|
+
# non-terminal `record_attempt(run_id:, partition_key:, summary:, error:)` transition.
|
|
199
220
|
# c.indexer.partition_run_store = nil
|
|
200
221
|
# c.indexer.partition_run_ttl_s = 86_400
|
|
201
222
|
|
|
@@ -113,9 +113,12 @@ module SearchEngine
|
|
|
113
113
|
def timeout_result(store, run_id, snapshot)
|
|
114
114
|
mark_non_terminal_failed(store, run_id, snapshot)
|
|
115
115
|
snapshot = store.snapshot(run_id: run_id) || snapshot
|
|
116
|
+
current_result = SearchEngine::IndexingRun.aggregate_result(snapshot)
|
|
117
|
+
return finish_success(snapshot, current_result) if current_result[:status] == :ok
|
|
118
|
+
|
|
116
119
|
result = failed_result(
|
|
117
120
|
snapshot,
|
|
118
|
-
|
|
121
|
+
current_result,
|
|
119
122
|
sample_error: "SearchEngine async partition indexing timed out for run #{run_id}"
|
|
120
123
|
)
|
|
121
124
|
instrument('search_engine.indexing_run.failed', event_payload(snapshot, result: result))
|
|
@@ -136,6 +139,9 @@ module SearchEngine
|
|
|
136
139
|
partition_key: partition_key,
|
|
137
140
|
error: 'partition did not finish before timeout'
|
|
138
141
|
)
|
|
142
|
+
rescue SearchEngine::IndexingRunStore::StaleRun
|
|
143
|
+
# The partition completed between the snapshot and terminal transition.
|
|
144
|
+
# Re-read the run after this pass instead of overwriting terminal success.
|
|
139
145
|
end
|
|
140
146
|
end
|
|
141
147
|
private_class_method :mark_non_terminal_failed
|
|
@@ -328,8 +328,7 @@ module SearchEngine
|
|
|
328
328
|
|
|
329
329
|
def invalid_type_message(expected, got)
|
|
330
330
|
got_class = got.nil? ? 'NilClass' : got.class.name
|
|
331
|
-
|
|
332
|
-
"Invalid type (expected #{expected}, got #{got_class}: \"#{got_preview}\")."
|
|
331
|
+
"Invalid type (expected #{expected}, got #{got_class})."
|
|
333
332
|
end
|
|
334
333
|
|
|
335
334
|
def validate_required_and_unknown!(klass, present_keys, allowed_keys, required_keys)
|
|
@@ -490,7 +489,8 @@ module SearchEngine
|
|
|
490
489
|
end
|
|
491
490
|
end
|
|
492
491
|
|
|
493
|
-
def import_documents!(klass, docs, into:, partition: nil)
|
|
492
|
+
def import_documents!(klass, docs, into:, partition: nil, on_failure: :raise)
|
|
493
|
+
failure_mode = normalize_failure_mode!(on_failure)
|
|
494
494
|
collection = resolve_target_collection(klass, into: into, partition: partition)
|
|
495
495
|
if docs.empty?
|
|
496
496
|
return {
|
|
@@ -499,13 +499,18 @@ module SearchEngine
|
|
|
499
499
|
success_count: 0,
|
|
500
500
|
failure_count: 0,
|
|
501
501
|
bytes_sent: 0,
|
|
502
|
-
|
|
502
|
+
errors_sample: [].freeze,
|
|
503
|
+
row_results: [].freeze
|
|
503
504
|
}
|
|
504
505
|
end
|
|
505
506
|
|
|
506
507
|
count, bytes, jsonl = encode_jsonl!(docs)
|
|
507
508
|
raw = SearchEngine.client.import_documents(collection: collection, jsonl: jsonl, action: :upsert)
|
|
508
|
-
|
|
509
|
+
row_results = parse_import_response(raw)
|
|
510
|
+
validate_response_count!(row_results, count, collection)
|
|
511
|
+
success_count = row_results.count { |row| row[:success] }
|
|
512
|
+
failure_count = row_results.length - success_count
|
|
513
|
+
errors_sample = row_results.filter_map { |row| row[:error] unless row[:success] }.first(5).freeze
|
|
509
514
|
|
|
510
515
|
result = {
|
|
511
516
|
collection: collection,
|
|
@@ -513,11 +518,11 @@ module SearchEngine
|
|
|
513
518
|
success_count: success_count,
|
|
514
519
|
failure_count: failure_count,
|
|
515
520
|
bytes_sent: bytes,
|
|
516
|
-
|
|
517
|
-
|
|
521
|
+
errors_sample: errors_sample,
|
|
522
|
+
row_results: row_results
|
|
518
523
|
}
|
|
519
524
|
|
|
520
|
-
if failure_count.positive?
|
|
525
|
+
if failure_mode == :raise && failure_count.positive?
|
|
521
526
|
sample = errors_sample&.first
|
|
522
527
|
msg = "Typesense import failed for #{failure_count}/#{count} document(s)"
|
|
523
528
|
msg = "#{msg} (e.g., #{sample})" if sample
|
|
@@ -532,7 +537,28 @@ module SearchEngine
|
|
|
532
537
|
end
|
|
533
538
|
|
|
534
539
|
def parse_import_response(raw)
|
|
535
|
-
SearchEngine::Indexer::ImportResponseParser.
|
|
540
|
+
SearchEngine::Indexer::ImportResponseParser.parse_rows(raw)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def normalize_failure_mode!(mode)
|
|
544
|
+
return mode if %i[raise return].include?(mode)
|
|
545
|
+
|
|
546
|
+
raise SearchEngine::Errors::InvalidParams,
|
|
547
|
+
'on_failure must be :raise or :return'
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def validate_response_count!(row_results, submitted_count, collection)
|
|
551
|
+
response_count = row_results.length
|
|
552
|
+
return if response_count == submitted_count
|
|
553
|
+
|
|
554
|
+
raise SearchEngine::Errors::InvalidParams.new(
|
|
555
|
+
"Typesense import response row count mismatch: expected #{submitted_count}, got #{response_count}",
|
|
556
|
+
details: {
|
|
557
|
+
collection: collection,
|
|
558
|
+
submitted_count: submitted_count,
|
|
559
|
+
response_count: response_count
|
|
560
|
+
}
|
|
561
|
+
)
|
|
536
562
|
end
|
|
537
563
|
|
|
538
564
|
def normalize_records_input(records)
|
|
@@ -656,11 +682,13 @@ module SearchEngine
|
|
|
656
682
|
# @param data [Enumerable<Hash>, nil]
|
|
657
683
|
# @param into [String, nil]
|
|
658
684
|
# @param partition [Object, nil]
|
|
659
|
-
# @
|
|
685
|
+
# @param on_failure [Symbol] :raise (default) or :return
|
|
686
|
+
# @return [Hash] payload-safe stats including ordered :row_results
|
|
660
687
|
# @raise [SearchEngine::Errors::InvalidParams]
|
|
661
|
-
def upsert_bulk(records: nil, data: nil, into: nil, partition: nil)
|
|
688
|
+
def upsert_bulk(records: nil, data: nil, into: nil, partition: nil, on_failure: :raise)
|
|
689
|
+
Helpers.normalize_failure_mode!(on_failure)
|
|
662
690
|
docs = Helpers.prepare_documents(self, records: records, data: data)
|
|
663
|
-
Helpers.import_documents!(self, docs, into: into, partition: partition)
|
|
691
|
+
Helpers.import_documents!(self, docs, into: into, partition: partition, on_failure: on_failure)
|
|
664
692
|
end
|
|
665
693
|
end
|
|
666
694
|
end
|