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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +184 -1
  4. data/app/search_engine/search_engine/index_partition_job.rb +40 -5
  5. data/app/search_engine/search_engine/postgres_outbox/drain_job.rb +4 -3
  6. data/lib/generators/search_engine/install/templates/initializer.rb.tt +22 -1
  7. data/lib/search_engine/async_partition_coordinator.rb +7 -1
  8. data/lib/search_engine/base/creation.rb +40 -12
  9. data/lib/search_engine/cascade.rb +6 -96
  10. data/lib/search_engine/cli.rb +26 -6
  11. data/lib/search_engine/config/validators.rb +21 -0
  12. data/lib/search_engine/config.rb +25 -2
  13. data/lib/search_engine/dispatcher.rb +18 -4
  14. data/lib/search_engine/errors.rb +32 -2
  15. data/lib/search_engine/indexer/import_dispatcher.rb +18 -4
  16. data/lib/search_engine/indexer/import_response_parser.rb +109 -61
  17. data/lib/search_engine/indexing_run.rb +1 -0
  18. data/lib/search_engine/indexing_run_store/rails_cache.rb +37 -1
  19. data/lib/search_engine/indexing_run_store.rb +1 -1
  20. data/lib/search_engine/observability.rb +64 -1
  21. data/lib/search_engine/postgres_outbox/drainer.rb +127 -55
  22. data/lib/search_engine/postgres_outbox/event.rb +3 -1
  23. data/lib/search_engine/postgres_outbox/event_processor.rb +79 -13
  24. data/lib/search_engine/postgres_outbox/migration_helpers.rb +47 -8
  25. data/lib/search_engine/postgres_outbox/processor_result.rb +92 -3
  26. data/lib/search_engine/postgres_outbox/repository.rb +942 -238
  27. data/lib/search_engine/schema.rb +246 -32
  28. data/lib/search_engine/version.rb +1 -1
  29. data/lib/tasks/search_engine.rake +44 -11
  30. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ee014706c77ccb6794d31e57efbc2e2efe3f5dd167e18adb2ada205678fdea1
4
- data.tar.gz: 11544aa9e90f22be02e65df7e848c4a66ef452f544e9f9ee0ea84ba9dfe31871
3
+ metadata.gz: dd7e450cd267dab4d557451ce255f20707b07d2a97cf42b143dfdc601561668f
4
+ data.tar.gz: 29e7f4f858407039f8e477a8d0f0fc1b9985e3922214bae03def126065e31106
5
5
  SHA512:
6
- metadata.gz: 40d1c46d229a68f0f04402d3fed7ea62fa68d91d76bb92096e2062667518c68f19cf32d5ed3155140c304a38328714e19fd718f950f21cd6f1f890848c827075
7
- data.tar.gz: 6e4e43fd5ca84f0f53ba3cc8a06d1dc2eadd43be935b54f4bdc2fcd7aef38e1c860a6e37c38cd73acdf1c4a8683d769bc4a58e80bd0ab3f356eed4de37e393e7
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(error, payload: safe_payload.merge(metadata: metadata || {}), run_id: run_id,
68
- partition_key: partition_key)
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) && executions.to_i < retry_policy.attempts
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 => stale_error
178
- instrument_stale_run(stale_error, payload: payload, run_id: run_id, partition_key: partition_key)
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
- SearchEngine::IndexingRun.aggregate_result(snapshot),
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
- got_preview = got.is_a?(String) ? got[0, 50] : got.to_s[0, 50]
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
- response: nil
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
- success_count, failure_count, errors_sample = parse_import_response(raw)
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
- response: raw,
517
- errors_sample: errors_sample
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.parse(raw)
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
- # @return [Hash] stats payload with keys: :collection, :docs_count, :success_count, :failure_count, :bytes_sent, :response
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