search-engine-for-typesense 30.1.8.20 → 30.1.8.24

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +219 -7
  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/generators/search_engine/model/model_generator.rb +3 -1
  8. data/lib/generators/search_engine/model/templates/model.rb.tt +1 -0
  9. data/lib/search_engine/active_record_syncable.rb +2 -2
  10. data/lib/search_engine/async_partition_coordinator.rb +7 -1
  11. data/lib/search_engine/base/creation.rb +43 -14
  12. data/lib/search_engine/cascade.rb +6 -96
  13. data/lib/search_engine/cli.rb +26 -6
  14. data/lib/search_engine/config/validators.rb +21 -0
  15. data/lib/search_engine/config.rb +25 -2
  16. data/lib/search_engine/dispatcher.rb +18 -4
  17. data/lib/search_engine/errors.rb +32 -2
  18. data/lib/search_engine/hydration/materializers.rb +5 -420
  19. data/lib/search_engine/indexer/bulk_import.rb +15 -7
  20. data/lib/search_engine/indexer/import_dispatcher.rb +18 -4
  21. data/lib/search_engine/indexer/import_response_parser.rb +109 -61
  22. data/lib/search_engine/indexing_run.rb +1 -0
  23. data/lib/search_engine/indexing_run_store/rails_cache.rb +37 -1
  24. data/lib/search_engine/indexing_run_store.rb +1 -1
  25. data/lib/search_engine/mapper.rb +2 -8
  26. data/lib/search_engine/observability.rb +64 -1
  27. data/lib/search_engine/postgres_outbox/drainer.rb +127 -55
  28. data/lib/search_engine/postgres_outbox/event.rb +3 -1
  29. data/lib/search_engine/postgres_outbox/event_processor.rb +79 -13
  30. data/lib/search_engine/postgres_outbox/migration_helpers.rb +47 -8
  31. data/lib/search_engine/postgres_outbox/processor_result.rb +92 -3
  32. data/lib/search_engine/postgres_outbox/repository.rb +942 -238
  33. data/lib/search_engine/schema.rb +246 -32
  34. data/lib/search_engine/sources/sql_source.rb +15 -27
  35. data/lib/search_engine/version.rb +1 -1
  36. data/lib/tasks/search_engine.rake +44 -11
  37. 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: 5f32e5d4e9f5b31b0ce518fc880b11df77f7023d759c876bbae7e5cc4ff81202
4
+ data.tar.gz: 541c01e233fe52c156e456b41a7caa77cde65d7517d9e876da4d026944a368e9
5
5
  SHA512:
6
- metadata.gz: 40d1c46d229a68f0f04402d3fed7ea62fa68d91d76bb92096e2062667518c68f19cf32d5ed3155140c304a38328714e19fd718f950f21cd6f1f890848c827075
7
- data.tar.gz: 6e4e43fd5ca84f0f53ba3cc8a06d1dc2eadd43be935b54f4bdc2fcd7aef38e1c860a6e37c38cd73acdf1c4a8683d769bc4a58e80bd0ab3f356eed4de37e393e7
6
+ metadata.gz: b2f888f079aa3991467920c1f66ca4538feb0043710c1ccf6ad76a3ecef16a26243db14921f75ea3fea09387331021fb9088c6041798f0999b6d924d10389902
7
+ data.tar.gz: fe6e8b4b9a36b1b4de3e7952d68e2309cab02a62caa2306d8ecccd445f91fed30db9c065c849f4f9bb382bcd7189fb9b8c3bec239e8de9229fc7964f498ff8d6
data/CHANGELOG.md ADDED
@@ -0,0 +1,74 @@
1
+ # Changelog
2
+
3
+ ## 30.1.8.24
4
+
5
+ - Preserve caller transactions during PostgreSQL streaming by rejecting active transactions before opening a cursor.
6
+ Apply statement timeouts inside the streaming transaction and restore connection settings on completion or failure.
7
+ - Synchronize both record creation and updates after commit, and propagate failed custom document identity calculations.
8
+ - Propagate parallel import worker failures and timeouts instead of returning successful summaries for incomplete imports.
9
+ - Execute ID-filtered queries through normal search materialization so text filters, selection, and memoization apply.
10
+ - Use native Typesense joins without incomplete client-side key lookups. Missing references and invalid search
11
+ configurations now raise errors instead of returning empty results. Existing joins require valid server references.
12
+ - Allow upserts to omit server-generated embeddings and preserve boolean coercions that produce false.
13
+ - Correct generated models, documented declarations, and demo collection references and timestamp mappings.
14
+
15
+ ## 30.1.8.23
16
+
17
+ - Run retained-physical alias rollbacks through the same cutover guard. Rollback accepts explicit destination
18
+ and expected-current compare-and-swap targets, validates older retained physicals, and makes legacy retries a
19
+ conservative no-op instead of toggling the alias forward again.
20
+ - Make row-trigger notification payloads constant per source table and collection so PostgreSQL folds repeated
21
+ notifications from one transaction into one low-latency wakeup. Durable event identity remains in the outbox
22
+ table, which continues to be the source of truth.
23
+ - Add `replace_search_engine_outbox_trigger_function` for forward migrations that only need to refresh generated
24
+ function bodies, without dropping triggers or taking avoidable trigger DDL locks on source tables.
25
+ - Renew target-scoped delivery leases before every dependency-ordered collection group. Deliveries whose fenced
26
+ lease cannot be renewed are never passed to a processor, so slow earlier groups cannot consume the lease budget
27
+ of later groups in the same claim.
28
+ - Add opt-in `postgres_outbox.clear_cache_after_write` invalidation. Enabled hosts clear Typesense's server-side
29
+ search cache before acknowledging each processed collection group; a clear failure keeps the group retryable.
30
+
31
+ No database migration is added by this release. Existing PostgreSQL outbox delivery/slot migrations remain
32
+ compatible.
33
+
34
+ ## 30.1.8.22
35
+
36
+ - Add `schema.around_rebuild`, an exact-once guard around full physical create/index/alias-swap/retention
37
+ lifecycles. In-place schema updates remain outside the guard, while guard and indexing failures propagate
38
+ without leaking interrupted physical collections.
39
+ - Add PostgreSQL delivery-target cutover locks. Full rebuilds can pause new claims, wait for acknowledged
40
+ processing deliveries, and exclude cooperating direct Typesense writers with one deterministic target key.
41
+ Locks are session-safe, timeout-bounded, and automatically released when the database session dies.
42
+ - Gate every delivery claim before target row work. Timed-out leases are reclaimed only for the bounded rows
43
+ actually selected; unselected rows remain `processing`, and stale older operations are settled before newer
44
+ siblings so delete/recreation ordering cannot reverse.
45
+ - Pin built-in outbox upserts and deletes to one resolved physical collection for the claimed operation, so a
46
+ delayed stale HTTP request cannot follow a logical alias onto a replacement collection after cutover.
47
+ - Reject standalone `reset_stale_processing!` in delivery mode because reset without same-transaction reclaim
48
+ can create false quiescence. Single-target event mode is unchanged.
49
+ - Make forced cascade rebuild failures propagate instead of downgrading to live partition imports.
50
+ - Mark `search_engine:index:rebuild` and `search_engine:index:rebuild_partition` as live-maintenance paths. When
51
+ `schema.around_rebuild` is configured, they now require explicit `ALLOW_LIVE_INDEX_MAINTENANCE=true` after
52
+ operators pause outbox consumers and direct writers. `search_engine:schema:apply` accepts
53
+ `FORCE_REBUILD=true` as the explicit guarded full-data rebuild path.
54
+
55
+ No database migration is added by this release. Existing PostgreSQL outbox delivery/slot migrations remain
56
+ compatible.
57
+
58
+ ## 30.1.8.21
59
+
60
+ - Bound PostgreSQL outbox claim ranking and harden delivery coalescing with parent-first locking, fenced
61
+ delivery leases, lock-order-safe stale resets, and per-event processing failure isolation.
62
+ - Add explicit, audited, idempotent delivery-target retirement with a required dry-run/apply decision. Target
63
+ retirement never infers intent from configuration removal and does not alter drain slots.
64
+ - Add strict ordered Typesense import response parsing and `upsert_bulk(..., on_failure: :return)` for stable
65
+ row-level outcomes. Malformed, ambiguous, short, and long responses now fail closed.
66
+ - Remove raw Typesense `response` data from bulk upsert result/error metadata because it can contain submitted
67
+ documents. Callers that used `result[:response]` must migrate to `row_results` and aggregate counters.
68
+ - Preserve safe structured error metadata while recursively redacting secrets and payload-bearing fields.
69
+ - Reject unsupported dispatch configuration/overrides instead of silently falling back to inline execution.
70
+ - Make partial async partition imports retryable and observable. Custom partition run stores must add
71
+ `record_attempt(run_id:, partition_key:, summary:, error:)` as a non-terminal transition.
72
+
73
+ No database migration is added by this release. Existing PostgreSQL outbox delivery/slot migrations remain
74
+ compatible.
data/README.md CHANGED
@@ -26,6 +26,7 @@ SearchEngine.configure do |c|
26
26
  c.port = 8108
27
27
  c.protocol = "http"
28
28
  c.api_key = ENV.fetch("TYPESENSE_API_KEY")
29
+ c.default_infix = "off"
29
30
  end
30
31
  ```
31
32
 
@@ -33,10 +34,10 @@ end
33
34
  class SearchEngine::Product < SearchEngine::Base
34
35
  collection :products
35
36
 
36
- attribute :id, :integer
37
+ identify_by :id
37
38
  attribute :name, :string
38
39
 
39
- query_by %i[name brand description]
40
+ query_by :name
40
41
  end
41
42
 
42
43
  SearchEngine::Product.where(name: "milk").select(:id, :name).limit(5).to_a
@@ -58,22 +59,50 @@ SearchEngine.configure do |c|
58
59
  end
59
60
  ```
60
61
 
62
+ SQL sources stream PostgreSQL queries in their own read-only transaction. Start streaming
63
+ outside an existing transaction; an active transaction is rejected before cursor SQL runs.
64
+ The configured statement timeout applies only to the streaming transaction.
65
+
66
+ Joined filters execute as native Typesense joins and require valid collection references.
67
+ Search configuration and missing-reference errors propagate to callers; they do not
68
+ produce empty results or trigger client-side joins.
69
+
61
70
  ## Usage examples
62
71
 
63
72
  ```ruby
64
- # Model
73
+ # Models (the source objects below are the host application's ActiveRecord models)
74
+ class SearchEngine::Brand < SearchEngine::Base
75
+ collection "brands"
76
+ identify_by :id
77
+ attribute :name, :string
78
+ query_by :name
79
+ end
80
+
65
81
  class SearchEngine::Product < SearchEngine::Base
66
82
  collection "products"
67
83
 
68
- attribute :id, :integer
84
+ identify_by :id
69
85
  attribute :name, :string
86
+ attribute :price_cents, :integer, sort: true
87
+ attribute :brand_id, :string, facet: true
88
+ attribute :category, :string, facet: true
89
+ belongs_to :brands, collection: "brands", local_key: :brand_id, foreign_key: :id
90
+ query_by :name
91
+
92
+ index do
93
+ source :active_record, model: ::Product
94
+ map do |record|
95
+ { name: record.name, price_cents: record.price_cents,
96
+ brand_id: record.brand_id.to_s, category: record.category }
97
+ end
98
+ end
70
99
  end
71
100
 
72
101
  # Basic query
73
102
  SearchEngine::Product
74
103
  .where(name: "milk")
75
104
  # Explicit query_by always wins over model/global defaults
76
- .options(query_by: 'name,brand')
105
+ .options(query_by: 'name')
77
106
  .select(:id, :name)
78
107
  .order(price_cents: :asc)
79
108
  .limit(5)
@@ -95,7 +124,7 @@ rel = SearchEngine::Product
95
124
  params = rel.to_h # compiled Typesense params
96
125
 
97
126
  # Multi-search
98
- result_set = SearchEngine.multi_search(common: { query_by: SearchEngine.config.default_query_by }) do |m|
127
+ result_set = SearchEngine.multi_search(common: { query_by: "name" }) do |m|
99
128
  m.add :products, SearchEngine::Product.where("name:~rud").per(10)
100
129
  m.add :brands, SearchEngine::Brand.all.per(5)
101
130
  end
@@ -117,6 +146,11 @@ SearchEngine::Product.upsert_bulk(records: Product.limit(2))
117
146
  # Bulk upsert mapped payloads
118
147
  SearchEngine::Product.upsert_bulk(data: [mapped])
119
148
 
149
+ # Inspect row-level failures without repeating the import request
150
+ result = SearchEngine::Product.upsert_bulk(data: [mapped], on_failure: :return)
151
+ failed_rows = result[:row_results].reject { |row| row[:success] }
152
+ failed_rows.first # => { index: 0, success: false, status: 404, error: "..." }
153
+
120
154
  # Geo search
121
155
  class SearchEngine::Venue < SearchEngine::Base
122
156
  collection :venues
@@ -148,6 +182,15 @@ result = SearchEngine::Venue.all.order_geo(:location, from: { lat: 54.69, lng: 2
148
182
  result.hits.first.geo_distance_meters # => { "location" => 1234 }
149
183
  ```
150
184
 
185
+ `upsert_bulk` defaults to `on_failure: :raise`. Use the exact symbol `:return` when the caller needs to
186
+ handle valid Typesense row failures itself. Malformed responses and response/document count mismatches always
187
+ raise in either mode; the gem never guesses which submitted document a missing response row belongs to.
188
+
189
+ Since `30.1.8.21`, bulk results intentionally do not expose the raw Typesense `response` because import
190
+ responses may contain submitted documents. Use the ordered, frozen `row_results` entries (`index`, `success`,
191
+ `status`, `error`) and aggregate counters instead. This is a safety-related result-shape change for callers
192
+ that previously read `result[:response]`.
193
+
151
194
  ## Documentation
152
195
 
153
196
  See the [Docs](https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/index)
@@ -188,6 +231,124 @@ Use a shared `Rails.cache` backend, or provide `c.indexer.partition_run_store`,
188
231
  the parent indexing process can see the same run metadata. Size the queue carefully: worker concurrency
189
232
  multiplies with any per-partition `max_parallel` setting.
190
233
 
234
+ A custom partition run store must implement:
235
+
236
+ ```ruby
237
+ create_run(run_id:, collection:, collection_class_name:, into:, partitions:, ttl_s:)
238
+ mark_started(run_id:, partition_key:, job_id: nil)
239
+ record_attempt(run_id:, partition_key:, summary:, error:)
240
+ mark_succeeded(run_id:, partition_key:, summary:)
241
+ mark_failed(run_id:, partition_key:, error:)
242
+ snapshot(run_id:)
243
+ expire(run_id:)
244
+ ```
245
+
246
+ `record_attempt` is non-terminal: it must persist that partition's partial summary and representative error
247
+ while leaving the partition `running`, without changing sibling partitions. Row-level import failures raise
248
+ from `IndexPartitionJob`, so ActiveJob retries the partition. Replaying successful upserts is expected and
249
+ safe. Only retry exhaustion terminalizes the partition as failed.
250
+
251
+ Only `:inline` and `:active_job` (or their exact String equivalents) are supported for
252
+ `c.indexer.dispatch` and `c.indexer.partition_execution`. Unknown values such as `:sidekiq` fail configuration
253
+ validation instead of silently running inline. Explicit `:active_job` dispatch also fails if ActiveJob is not
254
+ available.
255
+
256
+ ## Guarded blue/green cutovers
257
+
258
+ `SearchEngine::Schema.apply!` can run an optional guard around the complete full-rebuild lifecycle: physical
259
+ collection creation, indexing, alias swap, and retention. `SearchEngine::Schema.rollback` uses the same guard
260
+ around target resolution, validation, and its alias swap. In-place schema updates intentionally do not invoke
261
+ the guard. The guard must yield exactly once; not yielding or yielding repeatedly raises `ArgumentError`.
262
+ Guard errors and indexing errors propagate, and an interrupted pre-swap physical collection is still cleaned
263
+ up.
264
+
265
+ In delivery-target outbox deployments, use the guard to stop new target claims, wait for already-claimed
266
+ deliveries to acknowledge, and prevent cooperating direct writers from crossing the alias swap:
267
+
268
+ ```ruby
269
+ # config/initializers/search_engine.rb
270
+ typesense_target_key = ENV.fetch("TYPESENSE_DELIVERY_TARGET_KEY")
271
+
272
+ SearchEngine.configure do |c|
273
+ c.schema.around_rebuild = lambda do |collection:, &rebuild|
274
+ Rails.logger.info("Guarding Typesense rebuild for #{collection} on #{typesense_target_key}")
275
+ SearchEngine::PostgresOutbox::Repository.new.with_delivery_target_claims_paused(
276
+ target_key: typesense_target_key,
277
+ timeout_s: SearchEngine.config.postgres_outbox.processing_timeout_s + 60,
278
+ poll_interval_s: 0.1,
279
+ &rebuild
280
+ )
281
+ end
282
+ end
283
+ ```
284
+
285
+ The target key is the exact delivery destination key, not the logical collection name. Claim transactions
286
+ automatically take the matching transaction-scoped shared PostgreSQL advisory lock. The rebuild guard takes
287
+ an exclusive session lock, then waits until that target has no `processing` deliveries. It deliberately does
288
+ not reset timed-out leases: lease age cannot prove that an old worker's external Typesense request has
289
+ stopped. A stuck processing delivery therefore times out the rebuild; recover it operationally before
290
+ retrying. PostgreSQL releases the session lock if the owning connection or process dies.
291
+ Set the guard timeout above `processing_timeout_s` with enough margin for the slowest legitimate external
292
+ write and scheduling delay; a shorter fixed timeout causes avoidable rebuild failures.
293
+
294
+ Every direct Typesense writer outside the delivery drainer must cooperate with the same exact target key:
295
+
296
+ ```ruby
297
+ SearchEngine::PostgresOutbox::Repository.new.with_delivery_target_writes_allowed(
298
+ target_key: typesense_target_key,
299
+ timeout_s: 5
300
+ ) do
301
+ SearchEngine::Product.upsert(record: product)
302
+ end
303
+ ```
304
+
305
+ Claimed delivery requests must resolve the logical alias once and write to that pinned physical collection until
306
+ the delivery is acknowledged. The built-in `EventProcessor` does this for both upserts and deletes. Custom
307
+ collection processors must follow the same rule: a stale worker whose HTTP request completes after lease reclaim
308
+ must only be able to mutate the retired physical collection, never whichever physical collection the logical alias
309
+ points to after a later cutover.
310
+
311
+ Rollback is conservative and retry-safe. The backward-compatible form rolls back one generation only when the
312
+ alias points at the newest retained physical; once it no longer does, repeating the call returns
313
+ `action: :already_rolled_back` without another swap. When newer orphaned physicals exist, or when an operator
314
+ needs an auditable destination, pass the retained target explicitly. Add `expected_current` for compare-and-swap
315
+ protection:
316
+
317
+ ```ruby
318
+ SearchEngine::Schema.rollback(
319
+ SearchEngine::Product,
320
+ to: "products_20250101_000000_001",
321
+ expected_current: "products_20250102_000000_001"
322
+ )
323
+ ```
324
+
325
+ The equivalent task remains backward compatible and also accepts both safety arguments:
326
+
327
+ ```sh
328
+ rails 'search_engine:schema:rollback[products]'
329
+ rails 'search_engine:schema:rollback[products,products_20250101_000000_001,products_20250102_000000_001]'
330
+ ```
331
+
332
+ An explicit destination must be a retained physical older than the current/expected source. Retrying after the
333
+ alias already reached that destination is a successful no-op. Any other expected-current mismatch raises
334
+ `SearchEngine::Schema::RollbackConflict` without changing the alias.
335
+
336
+ This protocol only gates delivery-target claims. Legacy single-target event claims have no target identity and
337
+ must be stopped before a rebuild. Likewise, an unguarded direct writer can still cross the swap. In
338
+ delivery-target mode, `reset_stale_processing!` rejects standalone use because reset without same-transaction
339
+ reclaim creates a false-quiescence gap. Ordinary claims directly reclaim only the bounded timed-out rows they
340
+ actually select; unselected stale rows remain `processing`, so a cutover continues to fail closed.
341
+
342
+ Forced cascade rebuilds use the guarded blue/green path and propagate any rebuild/guard failure. They never
343
+ downgrade to a live partition import. The maintenance tasks `search_engine:index:rebuild` and
344
+ `search_engine:index:rebuild_partition` are intentionally different: they write directly into the resolved
345
+ live collection and do not invoke `schema.around_rebuild`. When a guard is configured, these tasks refuse to
346
+ run unless `ALLOW_LIVE_INDEX_MAINTENANCE=true` is explicitly set. Pause all outbox consumers and direct writers
347
+ before using that override. For a guarded forced blue/green data rebuild, run
348
+ `FORCE_REBUILD=true rails 'search_engine:schema:apply[collection]'`. Without `FORCE_REBUILD`, schema apply may
349
+ complete as an in-place schema update and intentionally skip document reindexing. Hosts with no configured
350
+ guard retain the legacy live-task behavior.
351
+
191
352
  ## PostgreSQL outbox sync
192
353
 
193
354
  Rails callbacks are convenient for ordinary `create`, `update`, and `destroy` flows, but they do not see
@@ -199,7 +360,9 @@ The flow is:
199
360
 
200
361
  1. A row-level PostgreSQL trigger writes a durable outbox row in the same transaction as the source table
201
362
  change.
202
- 2. The trigger calls `pg_notify` as a low-latency nudge after commit.
363
+ 2. The trigger calls `pg_notify` as a low-latency nudge after commit. Its payload is constant for a given
364
+ source table and collection, so PostgreSQL can fold repeated row-level notifications from one transaction
365
+ into a single wakeup.
203
366
  3. A host-managed listener receives notifications, or falls back to polling, and enqueues
204
367
  `SearchEngine::PostgresOutbox::DrainJob`.
205
368
  4. The drainer claims pending rows, coalesces older rows for the same collection/document pair, orders
@@ -230,6 +393,7 @@ SearchEngine.configure do |c|
230
393
  c.postgres_outbox.drain_target_parallelism = 1
231
394
  c.postgres_outbox.drain_job_max_batches = 1
232
395
  c.postgres_outbox.drain_job_max_runtime_s = nil
396
+ c.postgres_outbox.clear_cache_after_write = false
233
397
  c.postgres_outbox.poll_interval_s = 5
234
398
  c.postgres_outbox.retention_s = 7.days.to_i
235
399
 
@@ -246,10 +410,28 @@ SearchEngine.configure do |c|
246
410
  end
247
411
  ```
248
412
 
413
+ When an upgrade changes only generated trigger-function behavior, migrations can call
414
+ `replace_search_engine_outbox_trigger_function` with the same arguments used to create the trigger. It runs
415
+ `CREATE OR REPLACE FUNCTION` without dropping and reattaching the existing trigger, avoiding unnecessary
416
+ table-level trigger DDL locks. The trigger must already exist.
417
+
249
418
  `batch_size` is the global fallback for all collections. Use `batch_sizes` when some collections are much
250
419
  lighter or heavier than others. Omitted drain limits use the per-collection values; explicit `limit:`
251
420
  arguments still override the map and use one global cap for that drain.
252
421
 
422
+ When searches set `use_cache`, enable `clear_cache_after_write` if incremental
423
+ writes must be visible before their outbox rows are acknowledged. The drainer
424
+ clears Typesense's server-side search cache once after each processed
425
+ collection group and before database acknowledgement. If cache clearing fails,
426
+ the group remains retryable and its idempotent document writes are replayed.
427
+ The option defaults to `false` so hosts that do not use server-side search
428
+ caching do not add an unnecessary API call.
429
+
430
+ In delivery-target mode, a claim can contain several dependency-ordered collection groups. The drainer renews
431
+ all unstarted fenced leases before each group and removes any delivery it no longer owns before calling the
432
+ processor. Hosts must still size each individual collection group so its worst-case Typesense retry sequence
433
+ fits below `processing_timeout_s`; renewal protects later groups, not an unbounded current group.
434
+
253
435
  Generate and edit the migrations:
254
436
 
255
437
  ```bash
@@ -341,6 +523,36 @@ hosts add the optional drain slot table.
341
523
  Processors still receive event objects and return event IDs; the parent event status is refreshed from the
342
524
  aggregate delivery states.
343
525
 
526
+ ### Retiring a delivery target
527
+
528
+ Removing a target from `delivery_targets` does not infer permission to discard its persisted backlog. Remove
529
+ the target from configuration first, keep process configuration stable, then explicitly dry-run and apply
530
+ retirement:
531
+
532
+ ```ruby
533
+ repository = SearchEngine::PostgresOutbox::Repository.new
534
+
535
+ preview = repository.retire_delivery_target!(
536
+ target_key: "mirror_a",
537
+ dry_run: true,
538
+ reason: "mirror_a was decommissioned"
539
+ )
540
+
541
+ result = repository.retire_delivery_target!(
542
+ target_key: "mirror_a",
543
+ dry_run: false,
544
+ reason: "mirror_a was decommissioned",
545
+ operator: "deploy-2026-07-11"
546
+ )
547
+ ```
548
+
549
+ Both calls reject a target that is still configured. Apply supersedes only that exact target's `pending`,
550
+ `processing`, and `failed` deliveries, clears leases, stores a complete JSON audit record in `last_error`, and
551
+ refreshes parent event status under parent-first locks. It is idempotent; a second apply reports zero rows.
552
+ Drain slots are scheduler bookkeeping and are deliberately not changed. Configuration is rechecked directly
553
+ before each database mutation, but Ruby configuration and PostgreSQL cannot share an atomic lock, so do not
554
+ change delivery-target configuration concurrently with retirement.
555
+
344
556
  Pair triggered source models with `sync_strategy: :postgres_outbox` so Active Record callbacks do not also
345
557
  write to Typesense for the same changes:
346
558
 
@@ -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
 
@@ -48,12 +48,14 @@ module SearchEngine
48
48
  return [] if raw.nil?
49
49
 
50
50
  tokens = raw.split(/[\s,]+/).map(&:strip).reject(&:empty?)
51
- tokens.map do |pair|
51
+ tokens.filter_map do |pair|
52
52
  name, type = pair.split(':', 2)
53
53
  raise Thor::Error, "invalid attribute token: #{pair.inspect} (expected name:type)" unless name
54
54
 
55
55
  type = (type || 'string').to_s
56
56
  normalized = normalize_type(type)
57
+ next if name.to_s.underscore == 'id'
58
+
57
59
  [name.to_s.underscore, normalized]
58
60
  end
59
61
  end
@@ -7,6 +7,7 @@
7
7
  # - https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/vector-search
8
8
  class SearchEngine::<%= class_name %> < SearchEngine::Base
9
9
  collection "<%= @collection_name %>"
10
+ identify_by :id
10
11
  <% Array(@attributes).each do |(name, type)| -%>
11
12
  attribute :<%= name %>, :<%= type %>
12
13
  <% end -%>
@@ -265,8 +265,8 @@ module SearchEngine
265
265
  end
266
266
 
267
267
  if timing == :after_commit
268
- ar_klass.after_create_commit :__se_syncable_upsert! if actions.include?(:create)
269
- ar_klass.after_update_commit :__se_syncable_upsert! if actions.include?(:update)
268
+ upsert_actions = actions & %i[create update]
269
+ ar_klass.after_commit :__se_syncable_upsert!, on: upsert_actions unless upsert_actions.empty?
270
270
  ar_klass.after_destroy_commit :__se_syncable_delete! if actions.include?(:destroy)
271
271
  else
272
272
  ar_klass.after_create :__se_syncable_upsert! if actions.include?(:create)
@@ -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