search-engine-for-typesense 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +148 -0
  4. data/app/search_engine/search_engine/app_info.rb +11 -0
  5. data/app/search_engine/search_engine/index_partition_job.rb +170 -0
  6. data/lib/generators/search_engine/install/install_generator.rb +20 -0
  7. data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
  8. data/lib/generators/search_engine/model/model_generator.rb +86 -0
  9. data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
  10. data/lib/search-engine-for-typesense.rb +12 -0
  11. data/lib/search_engine/active_record_syncable.rb +247 -0
  12. data/lib/search_engine/admin/stopwords.rb +125 -0
  13. data/lib/search_engine/admin/synonyms.rb +125 -0
  14. data/lib/search_engine/admin.rb +12 -0
  15. data/lib/search_engine/ast/and.rb +52 -0
  16. data/lib/search_engine/ast/binary_op.rb +75 -0
  17. data/lib/search_engine/ast/eq.rb +19 -0
  18. data/lib/search_engine/ast/group.rb +18 -0
  19. data/lib/search_engine/ast/gt.rb +12 -0
  20. data/lib/search_engine/ast/gte.rb +12 -0
  21. data/lib/search_engine/ast/in.rb +28 -0
  22. data/lib/search_engine/ast/lt.rb +12 -0
  23. data/lib/search_engine/ast/lte.rb +12 -0
  24. data/lib/search_engine/ast/matches.rb +55 -0
  25. data/lib/search_engine/ast/node.rb +176 -0
  26. data/lib/search_engine/ast/not_eq.rb +13 -0
  27. data/lib/search_engine/ast/not_in.rb +24 -0
  28. data/lib/search_engine/ast/or.rb +52 -0
  29. data/lib/search_engine/ast/prefix.rb +51 -0
  30. data/lib/search_engine/ast/raw.rb +41 -0
  31. data/lib/search_engine/ast/unary_op.rb +43 -0
  32. data/lib/search_engine/ast.rb +101 -0
  33. data/lib/search_engine/base/creation.rb +727 -0
  34. data/lib/search_engine/base/deletion.rb +80 -0
  35. data/lib/search_engine/base/display_coercions.rb +36 -0
  36. data/lib/search_engine/base/hydration.rb +312 -0
  37. data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
  38. data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
  39. data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
  40. data/lib/search_engine/base/index_maintenance.rb +459 -0
  41. data/lib/search_engine/base/indexing_dsl.rb +255 -0
  42. data/lib/search_engine/base/joins.rb +479 -0
  43. data/lib/search_engine/base/model_dsl.rb +472 -0
  44. data/lib/search_engine/base/presets.rb +43 -0
  45. data/lib/search_engine/base/pretty_printer.rb +315 -0
  46. data/lib/search_engine/base/relation_delegation.rb +42 -0
  47. data/lib/search_engine/base/scopes.rb +113 -0
  48. data/lib/search_engine/base/updating.rb +92 -0
  49. data/lib/search_engine/base.rb +38 -0
  50. data/lib/search_engine/bulk.rb +284 -0
  51. data/lib/search_engine/cache.rb +33 -0
  52. data/lib/search_engine/cascade.rb +531 -0
  53. data/lib/search_engine/cli/doctor.rb +631 -0
  54. data/lib/search_engine/cli/support.rb +217 -0
  55. data/lib/search_engine/cli.rb +222 -0
  56. data/lib/search_engine/client/http_adapter.rb +63 -0
  57. data/lib/search_engine/client/request_builder.rb +92 -0
  58. data/lib/search_engine/client/services/base.rb +74 -0
  59. data/lib/search_engine/client/services/collections.rb +161 -0
  60. data/lib/search_engine/client/services/documents.rb +214 -0
  61. data/lib/search_engine/client/services/operations.rb +152 -0
  62. data/lib/search_engine/client/services/search.rb +190 -0
  63. data/lib/search_engine/client/services.rb +29 -0
  64. data/lib/search_engine/client.rb +765 -0
  65. data/lib/search_engine/client_options.rb +20 -0
  66. data/lib/search_engine/collection_resolver.rb +191 -0
  67. data/lib/search_engine/collections_graph.rb +330 -0
  68. data/lib/search_engine/compiled_params.rb +143 -0
  69. data/lib/search_engine/compiler.rb +383 -0
  70. data/lib/search_engine/config/observability.rb +27 -0
  71. data/lib/search_engine/config/presets.rb +92 -0
  72. data/lib/search_engine/config/selection.rb +16 -0
  73. data/lib/search_engine/config/typesense.rb +48 -0
  74. data/lib/search_engine/config/validators.rb +97 -0
  75. data/lib/search_engine/config.rb +917 -0
  76. data/lib/search_engine/console_helpers.rb +130 -0
  77. data/lib/search_engine/deletion.rb +103 -0
  78. data/lib/search_engine/dispatcher.rb +125 -0
  79. data/lib/search_engine/dsl/parser.rb +582 -0
  80. data/lib/search_engine/engine.rb +167 -0
  81. data/lib/search_engine/errors.rb +290 -0
  82. data/lib/search_engine/filters/sanitizer.rb +189 -0
  83. data/lib/search_engine/hydration/materializers.rb +808 -0
  84. data/lib/search_engine/hydration/selection_context.rb +96 -0
  85. data/lib/search_engine/indexer/batch_planner.rb +76 -0
  86. data/lib/search_engine/indexer/bulk_import.rb +626 -0
  87. data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
  88. data/lib/search_engine/indexer/retry_policy.rb +103 -0
  89. data/lib/search_engine/indexer.rb +747 -0
  90. data/lib/search_engine/instrumentation.rb +308 -0
  91. data/lib/search_engine/joins/guard.rb +202 -0
  92. data/lib/search_engine/joins/resolver.rb +95 -0
  93. data/lib/search_engine/logging/color.rb +78 -0
  94. data/lib/search_engine/logging/format_helpers.rb +92 -0
  95. data/lib/search_engine/logging/partition_progress.rb +53 -0
  96. data/lib/search_engine/logging_subscriber.rb +388 -0
  97. data/lib/search_engine/mapper.rb +785 -0
  98. data/lib/search_engine/multi.rb +286 -0
  99. data/lib/search_engine/multi_result.rb +186 -0
  100. data/lib/search_engine/notifications/compact_logger.rb +675 -0
  101. data/lib/search_engine/observability.rb +162 -0
  102. data/lib/search_engine/operations.rb +58 -0
  103. data/lib/search_engine/otel.rb +227 -0
  104. data/lib/search_engine/partitioner.rb +128 -0
  105. data/lib/search_engine/ranking_plan.rb +118 -0
  106. data/lib/search_engine/registry.rb +158 -0
  107. data/lib/search_engine/relation/compiler.rb +711 -0
  108. data/lib/search_engine/relation/deletion.rb +37 -0
  109. data/lib/search_engine/relation/dsl/filters.rb +624 -0
  110. data/lib/search_engine/relation/dsl/selection.rb +240 -0
  111. data/lib/search_engine/relation/dsl.rb +903 -0
  112. data/lib/search_engine/relation/dx/dry_run.rb +59 -0
  113. data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
  114. data/lib/search_engine/relation/dx.rb +231 -0
  115. data/lib/search_engine/relation/materializers.rb +118 -0
  116. data/lib/search_engine/relation/options.rb +138 -0
  117. data/lib/search_engine/relation/state.rb +274 -0
  118. data/lib/search_engine/relation/updating.rb +44 -0
  119. data/lib/search_engine/relation.rb +623 -0
  120. data/lib/search_engine/result.rb +664 -0
  121. data/lib/search_engine/schema.rb +1083 -0
  122. data/lib/search_engine/sources/active_record_source.rb +185 -0
  123. data/lib/search_engine/sources/base.rb +62 -0
  124. data/lib/search_engine/sources/lambda_source.rb +55 -0
  125. data/lib/search_engine/sources/sql_source.rb +196 -0
  126. data/lib/search_engine/sources.rb +71 -0
  127. data/lib/search_engine/stale_rules.rb +160 -0
  128. data/lib/search_engine/test/minitest_assertions.rb +57 -0
  129. data/lib/search_engine/test/offline_client.rb +134 -0
  130. data/lib/search_engine/test/rspec_matchers.rb +77 -0
  131. data/lib/search_engine/test/stub_client.rb +201 -0
  132. data/lib/search_engine/test.rb +66 -0
  133. data/lib/search_engine/test_autoload.rb +8 -0
  134. data/lib/search_engine/update.rb +35 -0
  135. data/lib/search_engine/version.rb +7 -0
  136. data/lib/search_engine.rb +332 -0
  137. data/lib/tasks/search_engine.rake +501 -0
  138. data/lib/tasks/search_engine_doctor.rake +16 -0
  139. metadata +225 -0
@@ -0,0 +1,501 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'search_engine'
6
+ require 'search_engine/cli'
7
+ require 'search_engine/logging/color'
8
+
9
+ namespace :search_engine do
10
+ # ------------------------- Schema tasks -------------------------
11
+ namespace :schema do
12
+ desc "Diff compiled schema vs live collection. Usage: rails 'search_engine:schema:diff[collection]'"
13
+ task :diff, [:collection] => :environment do |_t, args|
14
+ begin
15
+ klass = SearchEngine::Cli.resolve_collection!(args[:collection])
16
+ rescue ArgumentError => error
17
+ warn("Error: #{error.message}")
18
+ print_schema_usage
19
+ Kernel.exit(1)
20
+ end
21
+
22
+ payload = {
23
+ task: 'schema:diff',
24
+ collection: (klass.respond_to?(:collection) ? klass.collection : klass.name)
25
+ }
26
+ result = nil
27
+ SearchEngine::Cli.with_task_instrumentation('schema:diff', payload) do
28
+ result = SearchEngine::Schema.diff(klass)
29
+ end
30
+
31
+ diff = result[:diff]
32
+ drift = diff[:added_fields].any? ||
33
+ diff[:removed_fields].any? ||
34
+ !diff[:changed_fields].to_h.empty? ||
35
+ !diff[:collection_options].to_h.empty?
36
+
37
+ if SearchEngine::Cli.json_output?
38
+ out = { status: (drift ? 'drift' : 'in_sync'), diff: diff }
39
+ puts(JSON.generate(out))
40
+ else
41
+ puts(result[:pretty])
42
+ if SearchEngine::Cli.boolean_env?('VERBOSE')
43
+ puts("\n-- diff (verbose) --\n")
44
+ puts(JSON.pretty_generate(diff))
45
+ end
46
+ end
47
+
48
+ Kernel.exit(drift ? 10 : 0)
49
+ rescue StandardError => error
50
+ warn("schema:diff failed: #{error.message}")
51
+ Kernel.exit(1)
52
+ end
53
+
54
+ desc "Apply schema (create + reindex + swap + retention). Usage: rails 'search_engine:schema:apply[collection]'"
55
+ task :apply, [:collection] => :environment do |_t, args|
56
+ begin
57
+ klass = SearchEngine::Cli.resolve_collection!(args[:collection])
58
+ rescue ArgumentError => error
59
+ warn("Error: #{error.message}")
60
+ print_schema_usage
61
+ Kernel.exit(1)
62
+ end
63
+
64
+ payload = {
65
+ task: 'schema:apply',
66
+ collection: (klass.respond_to?(:collection) ? klass.collection : klass.name)
67
+ }
68
+ summary = nil
69
+ SearchEngine::Cli.with_task_instrumentation('schema:apply', payload) do
70
+ summary = SearchEngine::Schema.apply!(klass) do |physical|
71
+ # Inline reindex across all partitions into the new physical
72
+ parts = SearchEngine::Cli.partitions_for(klass)
73
+ parts = [nil] if parts.nil? || parts.respond_to?(:empty?) && parts.empty?
74
+ parts.each do |part|
75
+ SearchEngine::Indexer.rebuild_partition!(klass, partition: part, into: physical)
76
+ end
77
+ end
78
+ end
79
+
80
+ if SearchEngine::Cli.json_output?
81
+ puts(JSON.generate({ status: 'ok' }.merge(summary)))
82
+ else
83
+ puts("Logical: #{summary[:logical]}")
84
+ puts("New physical: #{summary[:new_physical]}")
85
+ puts("Previous physical: #{summary[:previous_physical] || 'none'}")
86
+ puts("Dropped old physicals: #{Array(summary[:dropped_physicals]).size}")
87
+ end
88
+
89
+ Kernel.exit(0)
90
+ rescue ArgumentError => error
91
+ warn("schema:apply failed: #{error.message}")
92
+ Kernel.exit(1)
93
+ rescue StandardError => error
94
+ warn("schema:apply failed: #{error.message}")
95
+ Kernel.exit(1)
96
+ end
97
+
98
+ desc "Rollback schema alias to previous retained physical. Usage: rails 'search_engine:schema:rollback[collection]'"
99
+ task :rollback, [:collection] => :environment do |_t, args|
100
+ begin
101
+ klass = SearchEngine::Cli.resolve_collection!(args[:collection])
102
+ rescue ArgumentError => error
103
+ warn("Error: #{error.message}")
104
+ print_schema_usage
105
+ Kernel.exit(1)
106
+ end
107
+
108
+ payload = {
109
+ task: 'schema:rollback',
110
+ collection: (klass.respond_to?(:collection) ? klass.collection : klass.name)
111
+ }
112
+ summary = nil
113
+ begin
114
+ SearchEngine::Cli.with_task_instrumentation('schema:rollback', payload) do
115
+ summary = SearchEngine::Schema.rollback(klass)
116
+ end
117
+ rescue ArgumentError => error
118
+ warn("schema:rollback not possible: #{error.message}")
119
+ Kernel.exit(2)
120
+ end
121
+
122
+ if SearchEngine::Cli.json_output?
123
+ puts(JSON.generate({ status: 'ok' }.merge(summary)))
124
+ else
125
+ puts("Logical: #{summary[:logical]}")
126
+ puts("New target: #{summary[:new_target]}")
127
+ puts("Previous target: #{summary[:previous_target] || 'none'}")
128
+ end
129
+
130
+ Kernel.exit(0)
131
+ rescue StandardError => error
132
+ warn("schema:rollback failed: #{error.message}")
133
+ Kernel.exit(1)
134
+ end
135
+ end
136
+
137
+ # ------------------------- Index tasks -------------------------
138
+ namespace :index do
139
+ desc "Rebuild entire index (all partitions or single). Usage: rails 'search_engine:index:rebuild[collection]'"
140
+ task :rebuild, [:collection] => :environment do |_t, args|
141
+ begin
142
+ klass = SearchEngine::Cli.resolve_collection!(args[:collection])
143
+ rescue ArgumentError => error
144
+ warn("Error: #{error.message}")
145
+ print_index_usage
146
+ Kernel.exit(1)
147
+ end
148
+
149
+ dry_run = SearchEngine::Cli.boolean_env?('DRY_RUN')
150
+ payload = {
151
+ task: 'index:rebuild',
152
+ collection: (klass.respond_to?(:collection) ? klass.collection : klass.name),
153
+ dry_run: dry_run
154
+ }
155
+
156
+ if dry_run
157
+ SearchEngine::Cli.with_task_instrumentation('index:rebuild', payload) do
158
+ partitions = Array(SearchEngine::Cli.partitions_for(klass))
159
+ partition = partitions.first
160
+ into = SearchEngine::Cli.resolve_into!(klass, partition: partition, into: nil)
161
+ enum = SearchEngine::Cli.docs_enum_for_first_batch(klass, partition)
162
+ preview = SearchEngine::Indexer.dry_run!(klass, into: into, enum: enum, action: :upsert)
163
+ if SearchEngine::Cli.json_output?
164
+ puts(JSON.generate(preview.merge(partition: partition)))
165
+ else
166
+ puts("Into: #{preview[:collection]} (partition=#{partition.inspect})")
167
+ puts("Action: #{preview[:action]}")
168
+ puts("Docs (first batch): #{preview[:docs_count]}, Bytes est: #{preview[:bytes_estimate]}")
169
+ if SearchEngine::Cli.boolean_env?('VERBOSE') && preview[:sample_line]
170
+ puts("Sample: #{preview[:sample_line]}")
171
+ end
172
+ end
173
+ end
174
+ Kernel.exit(0)
175
+ end
176
+
177
+ # Non-dry run
178
+ actions = []
179
+ SearchEngine::Cli.with_task_instrumentation('index:rebuild', payload) do
180
+ compiled = SearchEngine::Partitioner.for(klass)
181
+ if compiled
182
+ mode = SearchEngine::Cli.resolve_dispatch_mode(ENV['DISPATCH'])
183
+ compiled.partitions.each do |part|
184
+ res = SearchEngine::Dispatcher.dispatch!(
185
+ klass,
186
+ partition: part,
187
+ into: nil,
188
+ mode: mode,
189
+ queue: nil,
190
+ metadata: { task: 'index:rebuild' }
191
+ )
192
+ actions << res
193
+ end
194
+ else
195
+ # No partitioning DSL: single inline run
196
+ summary = SearchEngine::Indexer.rebuild_partition!(
197
+ klass,
198
+ partition: nil,
199
+ into: SearchEngine::Cli.resolve_into!(klass, partition: nil, into: nil)
200
+ )
201
+ # Aggregate a small sample of error messages for visibility when failed/partial
202
+ error_samples = []
203
+ if summary.failed_total.to_i.positive?
204
+ Array(summary.batches).each do |b|
205
+ samples = b[:errors_sample] || b['errors_sample']
206
+ Array(samples).each do |msg|
207
+ error_samples << msg
208
+ break if error_samples.size >= 5
209
+ end
210
+ break if error_samples.size >= 5
211
+ end
212
+ error_samples.uniq!
213
+ end
214
+ actions << {
215
+ mode: :inline,
216
+ indexer_summary: {
217
+ status: summary.status,
218
+ docs_total: summary.docs_total,
219
+ batches_total: summary.batches_total,
220
+ duration_ms_total: summary.duration_ms_total,
221
+ failed_total: summary.failed_total,
222
+ error_samples: (error_samples && !error_samples.empty? ? error_samples : nil)
223
+ },
224
+ partition: nil
225
+ }
226
+ end
227
+ end
228
+
229
+ if SearchEngine::Cli.json_output?
230
+ puts(JSON.generate({ status: 'ok', actions: actions }))
231
+ elsif actions.empty?
232
+ puts('No actions performed')
233
+ else
234
+ actions.each do |a|
235
+ if a[:mode] == :active_job
236
+ puts("Enqueued partition=#{a[:partition].inspect} to queue=#{a[:queue]} (job_id=#{a[:job_id]})")
237
+ next
238
+ end
239
+
240
+ sum = a[:indexer_summary]
241
+ # Support both Struct and Hash forms
242
+ status = sum.respond_to?(:status) ? sum.status : sum[:status]
243
+ docs_total = sum.respond_to?(:docs_total) ? sum.docs_total : sum[:docs_total]
244
+ batches_total = sum.respond_to?(:batches_total) ? sum.batches_total : sum[:batches_total]
245
+ duration_ms_total = sum.respond_to?(:duration_ms_total) ? sum.duration_ms_total : sum[:duration_ms_total]
246
+ failed_count = sum.respond_to?(:failed_total) ? sum.failed_total : sum[:failed_total]
247
+ success_count = sum.respond_to?(:success_total) ? sum.success_total : sum[:success_total]
248
+ status_color = SearchEngine::Logging::Color.for_partition_status(failed_count.to_i, success_count.to_i)
249
+ line = +''
250
+ line << SearchEngine::Logging::Color.apply("Imported partition=#{a[:partition].inspect}", status_color)
251
+ line << ' '
252
+ line << SearchEngine::Logging::Color.apply("status=#{status}", status_color)
253
+ line << ' '
254
+ line << "docs=#{docs_total}"
255
+ line << ' '
256
+ success_str = "success=#{success_count}"
257
+ line << (success_count.to_i.positive? ? SearchEngine::Logging::Color.apply(success_str, :green) : success_str)
258
+ line << ' '
259
+ failed_str = "failed=#{failed_count}"
260
+ line << (failed_count.to_i.positive? ? SearchEngine::Logging::Color.apply(failed_str, :red) : failed_str)
261
+ line << ' '
262
+ line << "batches=#{batches_total} "
263
+ line << "duration_ms=#{duration_ms_total}"
264
+ puts(line)
265
+
266
+ print_failures_if_any(status, sum)
267
+ end
268
+ end
269
+
270
+ Kernel.exit(0)
271
+ rescue StandardError => error
272
+ warn("index:rebuild failed: #{error.message}")
273
+ Kernel.exit(1)
274
+ end
275
+
276
+ desc "Rebuild a single partition. Usage: rails 'search_engine:index:rebuild_partition[collection,partition]'"
277
+ task :rebuild_partition, %i[collection partition] => :environment do |_t, args|
278
+ begin
279
+ klass = SearchEngine::Cli.resolve_collection!(args[:collection])
280
+ rescue ArgumentError => error
281
+ warn("Error: #{error.message}")
282
+ print_index_usage
283
+ Kernel.exit(1)
284
+ end
285
+ partition = SearchEngine::Cli.parse_partition(args[:partition])
286
+ payload = {
287
+ task: 'index:rebuild_partition',
288
+ collection: (klass.respond_to?(:collection) ? klass.collection : klass.name),
289
+ partition: partition
290
+ }
291
+
292
+ action = nil
293
+ SearchEngine::Cli.with_task_instrumentation('index:rebuild_partition', payload) do
294
+ mode = SearchEngine::Cli.resolve_dispatch_mode(ENV['DISPATCH'])
295
+ if mode == :active_job
296
+ action = SearchEngine::Dispatcher.dispatch!(
297
+ klass,
298
+ partition: partition,
299
+ into: nil,
300
+ mode: :active_job,
301
+ queue: nil,
302
+ metadata: { task: 'index:rebuild_partition' }
303
+ )
304
+ else
305
+ summary = SearchEngine::Indexer.rebuild_partition!(
306
+ klass,
307
+ partition: partition,
308
+ into: SearchEngine::Cli.resolve_into!(klass, partition: partition, into: nil)
309
+ )
310
+ action = {
311
+ mode: :inline,
312
+ indexer_summary: {
313
+ status: summary.status,
314
+ docs_total: summary.docs_total,
315
+ batches_total: summary.batches_total,
316
+ duration_ms_total: summary.duration_ms_total
317
+ }
318
+ }
319
+ end
320
+ end
321
+
322
+ if SearchEngine::Cli.json_output?
323
+ puts(JSON.generate({ status: 'ok' }.merge(action)))
324
+ elsif action[:mode] == :active_job
325
+ puts("Enqueued partition=#{partition.inspect} to queue=#{action[:queue]} (job_id=#{action[:job_id]})")
326
+ else
327
+ sum = action[:indexer_summary]
328
+ status = sum.respond_to?(:status) ? sum.status : sum[:status]
329
+ docs_total = sum.respond_to?(:docs_total) ? sum.docs_total : sum[:docs_total]
330
+ batches_total = sum.respond_to?(:batches_total) ? sum.batches_total : sum[:batches_total]
331
+ duration_ms_total = sum.respond_to?(:duration_ms_total) ? sum.duration_ms_total : sum[:duration_ms_total]
332
+ failed_count = sum.respond_to?(:failed_total) ? sum.failed_total : sum[:failed_total]
333
+ success_count = sum.respond_to?(:success_total) ? sum.success_total : sum[:success_total]
334
+ status_color = SearchEngine::Logging::Color.for_partition_status(failed_count.to_i, success_count.to_i)
335
+ line = +''
336
+ line << SearchEngine::Logging::Color.apply("Imported partition=#{partition.inspect}", status_color)
337
+ line << ' '
338
+ line << SearchEngine::Logging::Color.apply("status=#{status}", status_color)
339
+ line << ' '
340
+ line << "docs=#{docs_total}"
341
+ line << ' '
342
+ success_str = "success=#{success_count}"
343
+ line << (success_count.to_i.positive? ? SearchEngine::Logging::Color.apply(success_str, :green) : success_str)
344
+ line << ' '
345
+ failed_str = "failed=#{failed_count}"
346
+ line << (failed_count.to_i.positive? ? SearchEngine::Logging::Color.apply(failed_str, :red) : failed_str)
347
+ line << ' '
348
+ line << "batches=#{batches_total} "
349
+ line << "duration_ms=#{duration_ms_total}"
350
+ puts(line)
351
+ print_failures_if_any(status, sum)
352
+ end
353
+
354
+ Kernel.exit(0)
355
+ rescue StandardError => error
356
+ warn("index:rebuild_partition failed: #{error.message}")
357
+ Kernel.exit(1)
358
+ end
359
+
360
+ desc "Delete stale documents (by filter). Usage: rails 'search_engine:index:delete_stale[collection,partition]'"
361
+ task :delete_stale, %i[collection partition] => :environment do |_t, args|
362
+ begin
363
+ klass = SearchEngine::Cli.resolve_collection!(args[:collection])
364
+ rescue ArgumentError => error
365
+ warn("Error: #{error.message}")
366
+ print_index_usage
367
+ Kernel.exit(1)
368
+ end
369
+ partition = SearchEngine::Cli.parse_partition(args[:partition])
370
+ strict = SearchEngine::Cli.boolean_env?('STRICT')
371
+ dry_run = SearchEngine::Cli.boolean_env?('DRY_RUN')
372
+
373
+ payload = {
374
+ task: 'index:delete_stale',
375
+ collection: (klass.respond_to?(:collection) ? klass.collection : klass.name),
376
+ partition: partition,
377
+ dry_run: dry_run,
378
+ strict: strict
379
+ }
380
+
381
+ # Pre-check for filter definition
382
+ unless SearchEngine::StaleRules.defined_for?(klass)
383
+ msg = 'No stale rules defined for this collection.'
384
+ if strict
385
+ warn("STRICT mode: #{msg}")
386
+ Kernel.exit(3)
387
+ else
388
+ warn("Warning: #{msg} Skipping.")
389
+ Kernel.exit(0)
390
+ end
391
+ end
392
+
393
+ summary = nil
394
+ SearchEngine::Cli.with_task_instrumentation('index:delete_stale', payload) do
395
+ summary = SearchEngine::Indexer.delete_stale!(klass, partition: partition, dry_run: dry_run)
396
+ end
397
+
398
+ if SearchEngine::Cli.json_output?
399
+ puts(JSON.generate(summary))
400
+ elsif summary[:status] == :ok
401
+ puts(
402
+ "Deleted #{summary[:deleted_count]} docs from #{summary[:into]} " \
403
+ "(partition=#{summary[:partition].inspect}) " \
404
+ "in #{summary[:duration_ms]}ms"
405
+ )
406
+ elsif summary[:status] == :skipped
407
+ puts('Skipped (disabled or empty filter)')
408
+ else
409
+ puts("Failed: #{summary[:error_class]} #{summary[:message_truncated]}")
410
+ end
411
+
412
+ Kernel.exit(0)
413
+ rescue StandardError => error
414
+ warn("index:delete_stale failed: #{error.message}")
415
+ Kernel.exit(1)
416
+ end
417
+ end
418
+
419
+ # ------------------------- Helpers -------------------------
420
+ def print_failures_if_any(status, summary)
421
+ return if status == :ok
422
+
423
+ failed_total = summary.respond_to?(:failed_total) ? summary.failed_total : summary[:failed_total]
424
+ return unless failed_total.to_i.positive?
425
+
426
+ error_samples = build_error_samples_from_summary(summary)
427
+ sample_errors = error_samples && !error_samples.empty? ? " sample_errors=#{error_samples.join(' | ')}" : ''
428
+ begin
429
+ require 'search_engine/logging/color'
430
+ rescue LoadError
431
+ # no-op
432
+ end
433
+ if defined?(SearchEngine::Logging::Color)
434
+ line = SearchEngine::Logging::Color.apply("Failures=#{failed_total}", :red) + sample_errors
435
+ puts(line)
436
+ else
437
+ puts("Failures=#{failed_total}#{sample_errors}")
438
+ end
439
+ end
440
+
441
+ def build_error_samples_from_summary(sum)
442
+ if sum.respond_to?(:batches)
443
+ errs = []
444
+ Array(sum.batches).each do |b|
445
+ samples = b[:errors_sample] || b['errors_sample']
446
+ Array(samples).each do |msg|
447
+ errs << msg
448
+ break if errs.size >= 5
449
+ end
450
+ break if errs.size >= 5
451
+ end
452
+ errs.uniq
453
+ else
454
+ Array(sum[:error_samples])
455
+ end
456
+ end
457
+
458
+ def print_schema_usage
459
+ puts <<~USAGE
460
+ Usage:
461
+ rails 'search_engine:schema:diff[collection]'
462
+ rails 'search_engine:schema:apply[collection]'
463
+ rails 'search_engine:schema:rollback[collection]'
464
+
465
+ Examples:
466
+ rails 'search_engine:schema:diff[SearchEngine::Product]'
467
+ rails 'search_engine:schema:apply[products]'
468
+ rails 'search_engine:schema:rollback[products]'
469
+
470
+ Tips:
471
+ - Quote rake tasks with brackets to avoid shell globbing (e.g., zsh):
472
+ rails 'search_engine:index:rebuild_partition[SearchEngine::Product,42]'
473
+ USAGE
474
+ end
475
+
476
+ def print_index_usage
477
+ puts <<~USAGE
478
+ Usage:
479
+ rails 'search_engine:index:rebuild[collection]'
480
+ rails 'search_engine:index:rebuild_partition[collection,partition]'
481
+ rails 'search_engine:index:delete_stale[collection,partition]'
482
+
483
+ Environment:
484
+ DRY_RUN=1 Preview first batch only (no HTTP); also for delete_stale shows filter and estimation when enabled
485
+ DISPATCH=... Override dispatch mode for rebuild_partition (:inline or :active_job)
486
+ VERBOSE=1 More verbose output
487
+ FORMAT=json Machine-readable output
488
+ STRICT=1 For delete_stale: treat missing filter as violation (exit 3)
489
+
490
+ Examples:
491
+ rails 'search_engine:index:rebuild[SearchEngine::Product]'
492
+ rails 'search_engine:index:rebuild_partition[products,42]'
493
+ rails 'search_engine:index:delete_stale[SearchEngine::Product,42]'
494
+
495
+ Tips:
496
+ - Use brackets without spaces.
497
+ - Quote rake tasks with brackets to avoid shell globbing (e.g., zsh):
498
+ rails 'search_engine:index:rebuild_partition[SearchEngine::Product,42]'
499
+ USAGE
500
+ end
501
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'search_engine'
5
+ require 'search_engine/cli/doctor'
6
+
7
+ namespace :search_engine do
8
+ desc 'Run diagnostics checks. Usage: rails search_engine:doctor (FORMAT=table|json)'
9
+ task doctor: :environment do
10
+ exit_code = SearchEngine::Cli::Doctor.run
11
+ Kernel.exit(exit_code)
12
+ rescue StandardError => error
13
+ warn("doctor failed: #{error.message}")
14
+ Kernel.exit(1)
15
+ end
16
+ end