search-engine-for-typesense 30.1.8.18 → 30.1.8.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d799dd8ebda35a7687a0043fa6c2e64405ff6a4adac15756f2483ecf1c8ea963
4
- data.tar.gz: e408f30ec0d0cab5a053866f6db1985c9dd2e369555fc9412a9321ee3ac1ba5e
3
+ metadata.gz: 20f330c7ec29b4f4ea6366c48d23f05d5a089e47eaeba79f0ad741c2a83b09ec
4
+ data.tar.gz: 1359ad4c68d304d334c238bae7beddeee57a6350565b8b8e5b6b8d3f97c75e82
5
5
  SHA512:
6
- metadata.gz: 5b72688894bdb836651521e2217d305266096a18a4e616a6dc508e00c19ace5f2170baa79e127220ebd5fdb6ca21477b19a856c66d3e91b4b28d9fcec48f96d1
7
- data.tar.gz: 13f44c384d0485688f399799efaab02645f1cc2b261359c13b7f688a853a536f9a57fc3e6fadea4fead6222114887d639ead85150184b93c2d2832cf74624d9f
6
+ metadata.gz: 55191671cd8fb5dc620d25b605715b7608db8f8de48c220e7fe3d59727c239a9dd386574a421fd4477b6170f71c010575acf8b5c3e961a4fe2a455a568fe3e1c
7
+ data.tar.gz: f74ac6499069ed09918eac51273bcfcd5e82e0d91295001a9f3c5505b1ad69159f3c92f75c796d0cef43af29ee3159ecc79438c285fd15b2a4b0cb666d4618e1
data/README.md CHANGED
@@ -405,6 +405,11 @@ inspected before deletion because they contain the last error and retry state. A
405
405
  only rows with `status IN ('processed', 'superseded')` and `processed_at` older than
406
406
  `c.postgres_outbox.retention_s`.
407
407
 
408
+ In delivery-target mode, cleanup can first call
409
+ `SearchEngine::PostgresOutbox::Repository#refresh_terminal_delivery_event_statuses!`. This bounded helper
410
+ repairs parent events whose delivery rows are all terminal but whose parent status is still non-terminal,
411
+ then normal retention cleanup can delete the repaired parent rows and cascade their deliveries.
412
+
408
413
  ## Example app
409
414
 
410
415
  See `examples/demo_shop` — demonstrates single/multi search, JOINs, grouping, presets/curation, and DX/observability. Supports offline mode via the stub client (see [Testing](https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/testing)).
@@ -128,6 +128,24 @@ module SearchEngine
128
128
  rows
129
129
  end
130
130
 
131
+ # Refresh cleanup-eligible parent event statuses from terminal delivery rows.
132
+ #
133
+ # This repairs historical residue where every delivery row for an event is
134
+ # terminal but the parent event still has a non-terminal status. The
135
+ # candidate set is bounded before aggregate work to keep cleanup safe on
136
+ # large outbox tables.
137
+ #
138
+ # @param retention_s [Integer] retention window in seconds
139
+ # @param limit [Integer, nil] maximum parent events to refresh
140
+ # @return [Integer] refreshed parent event count
141
+ def refresh_terminal_delivery_event_statuses!(retention_s:, limit: nil)
142
+ return 0 unless delivery_table_exists?
143
+
144
+ rows = select_rows(terminal_delivery_event_status_refresh_sql(retention_s: retention_s, limit: limit))
145
+
146
+ rows.size
147
+ end
148
+
131
149
  # Check whether the optional drain slot table exists.
132
150
  #
133
151
  # @return [Boolean]
@@ -360,14 +378,27 @@ module SearchEngine
360
378
 
361
379
  def reset_stale_delivery_processing!
362
380
  execute(<<~SQL)
363
- UPDATE #{quoted_delivery_table}
364
- SET status = 'pending',
365
- locked_at = NULL,
366
- locked_by = NULL,
381
+ WITH reset_deliveries AS (
382
+ UPDATE #{quoted_delivery_table}
383
+ SET status = 'pending',
384
+ locked_at = NULL,
385
+ locked_by = NULL,
386
+ updated_at = CURRENT_TIMESTAMP
387
+ WHERE target_key = #{quote(target_key)}
388
+ AND status = 'processing'
389
+ AND locked_at < (CURRENT_TIMESTAMP - interval '#{processing_timeout_s} seconds')
390
+ RETURNING event_id
391
+ ),
392
+ aggregate AS (
393
+ #{event_status_aggregate_sql('SELECT event_id FROM reset_deliveries')}
394
+ )
395
+ UPDATE #{quoted_table} events
396
+ SET status = aggregate.status,
397
+ processed_at = #{aggregate_processed_at_sql},
398
+ last_error = aggregate.last_error,
367
399
  updated_at = CURRENT_TIMESTAMP
368
- WHERE target_key = #{quote(target_key)}
369
- AND status = 'processing'
370
- AND locked_at < (CURRENT_TIMESTAMP - interval '#{processing_timeout_s} seconds')
400
+ FROM aggregate
401
+ WHERE events.id = aggregate.event_id
371
402
  SQL
372
403
  end
373
404
 
@@ -504,10 +535,7 @@ module SearchEngine
504
535
  )
505
536
  UPDATE #{quoted_table} events
506
537
  SET status = aggregate.status,
507
- processed_at = CASE
508
- WHEN aggregate.status IN ('processed', 'superseded') THEN CURRENT_TIMESTAMP
509
- ELSE NULL
510
- END,
538
+ processed_at = #{aggregate_processed_at_sql},
511
539
  last_error = aggregate.last_error,
512
540
  updated_at = CURRENT_TIMESTAMP
513
541
  FROM aggregate
@@ -565,10 +593,7 @@ module SearchEngine
565
593
  )
566
594
  UPDATE #{quoted_table} events
567
595
  SET status = aggregate.status,
568
- processed_at = CASE
569
- WHEN aggregate.status IN ('processed', 'superseded') THEN CURRENT_TIMESTAMP
570
- ELSE NULL
571
- END,
596
+ processed_at = #{aggregate_processed_at_sql},
572
597
  last_error = aggregate.last_error,
573
598
  updated_at = CURRENT_TIMESTAMP
574
599
  FROM aggregate
@@ -595,47 +620,60 @@ module SearchEngine
595
620
  ids = Array(event_ids).compact
596
621
  return if ids.empty?
597
622
 
598
- execute(<<~SQL)
599
- UPDATE #{quoted_delivery_table}
600
- SET status = #{quote(status)},
601
- #{extra},
602
- locked_at = NULL,
603
- locked_by = NULL,
604
- updated_at = CURRENT_TIMESTAMP
605
- WHERE target_key = #{quote(target_key)}
606
- AND event_id IN (#{ids_sql(ids)})
607
- SQL
608
- refresh_event_statuses!(ids)
623
+ with_delivery_status_refresh(ids) do
624
+ execute(<<~SQL)
625
+ UPDATE #{quoted_delivery_table}
626
+ SET status = #{quote(status)},
627
+ #{extra},
628
+ locked_at = NULL,
629
+ locked_by = NULL,
630
+ updated_at = CURRENT_TIMESTAMP
631
+ WHERE target_key = #{quote(target_key)}
632
+ AND event_id IN (#{ids_sql(ids)})
633
+ SQL
634
+ end
609
635
  end
610
636
 
611
637
  def mark_delivery_retryable!(event_ids, error:)
612
- execute(<<~SQL)
613
- UPDATE #{quoted_delivery_table}
614
- SET attempts = attempts + 1,
615
- status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
616
- next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
617
- locked_at = NULL,
618
- locked_by = NULL,
619
- last_error = #{quote(truncate_error(error))},
620
- updated_at = CURRENT_TIMESTAMP
621
- WHERE target_key = #{quote(target_key)}
622
- AND event_id IN (#{ids_sql(event_ids)})
623
- SQL
624
- refresh_event_statuses!(event_ids)
638
+ with_delivery_status_refresh(event_ids) do
639
+ execute(<<~SQL)
640
+ UPDATE #{quoted_delivery_table}
641
+ SET attempts = attempts + 1,
642
+ status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
643
+ next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
644
+ locked_at = NULL,
645
+ locked_by = NULL,
646
+ last_error = #{quote(truncate_error(error))},
647
+ updated_at = CURRENT_TIMESTAMP
648
+ WHERE target_key = #{quote(target_key)}
649
+ AND event_id IN (#{ids_sql(event_ids)})
650
+ SQL
651
+ end
625
652
  end
626
653
 
627
654
  def mark_delivery_failed!(event_ids, error:)
628
- execute(<<~SQL)
629
- UPDATE #{quoted_delivery_table}
630
- SET status = 'failed',
631
- locked_at = NULL,
632
- locked_by = NULL,
633
- last_error = #{quote(truncate_error(error))},
634
- updated_at = CURRENT_TIMESTAMP
635
- WHERE target_key = #{quote(target_key)}
636
- AND event_id IN (#{ids_sql(event_ids)})
637
- SQL
638
- refresh_event_statuses!(event_ids)
655
+ with_delivery_status_refresh(event_ids) do
656
+ execute(<<~SQL)
657
+ UPDATE #{quoted_delivery_table}
658
+ SET status = 'failed',
659
+ locked_at = NULL,
660
+ locked_by = NULL,
661
+ last_error = #{quote(truncate_error(error))},
662
+ updated_at = CURRENT_TIMESTAMP
663
+ WHERE target_key = #{quote(target_key)}
664
+ AND event_id IN (#{ids_sql(event_ids)})
665
+ SQL
666
+ end
667
+ end
668
+
669
+ def with_delivery_status_refresh(event_ids)
670
+ ids = Array(event_ids).compact
671
+ return if ids.empty?
672
+
673
+ connection.transaction do
674
+ yield
675
+ refresh_event_statuses!(ids)
676
+ end
639
677
  end
640
678
 
641
679
  def refresh_event_statuses!(event_ids)
@@ -645,10 +683,7 @@ module SearchEngine
645
683
  execute(<<~SQL)
646
684
  UPDATE #{quoted_table} events
647
685
  SET status = aggregate.status,
648
- processed_at = CASE
649
- WHEN aggregate.status IN ('processed', 'superseded') THEN CURRENT_TIMESTAMP
650
- ELSE NULL
651
- END,
686
+ processed_at = #{aggregate_processed_at_sql},
652
687
  last_error = aggregate.last_error,
653
688
  updated_at = CURRENT_TIMESTAMP
654
689
  FROM (
@@ -668,6 +703,7 @@ module SearchEngine
668
703
  WHEN COUNT(*) FILTER (WHERE status = 'processed') > 0 THEN 'processed'
669
704
  ELSE 'pending'
670
705
  END AS status,
706
+ MAX(processed_at) FILTER (WHERE status IN ('processed', 'superseded')) AS terminal_processed_at,
671
707
  (ARRAY_AGG(last_error ORDER BY updated_at DESC) FILTER (WHERE last_error IS NOT NULL))[1] AS last_error
672
708
  FROM #{quoted_delivery_table}
673
709
  WHERE event_id IN (#{event_ids_sql})
@@ -675,6 +711,63 @@ module SearchEngine
675
711
  SQL
676
712
  end
677
713
 
714
+ def terminal_delivery_event_status_refresh_sql(retention_s:, limit:)
715
+ retention_seconds = [retention_s.to_i, 0].max
716
+ batch_limit = global_limit_for(limit)
717
+
718
+ <<~SQL
719
+ WITH candidate_events AS MATERIALIZED (
720
+ SELECT events.id
721
+ FROM #{quoted_table} events
722
+ WHERE events.status NOT IN ('processed', 'superseded')
723
+ AND EXISTS (
724
+ SELECT 1
725
+ FROM #{quoted_delivery_table} deliveries
726
+ WHERE deliveries.event_id = events.id
727
+ )
728
+ AND NOT EXISTS (
729
+ SELECT 1
730
+ FROM #{quoted_delivery_table} deliveries
731
+ WHERE deliveries.event_id = events.id
732
+ AND deliveries.status IN ('pending', 'processing', 'failed')
733
+ )
734
+ ORDER BY events.id ASC
735
+ LIMIT #{batch_limit}
736
+ FOR UPDATE SKIP LOCKED
737
+ ),
738
+ eligible_events AS (
739
+ SELECT candidate_events.id
740
+ FROM candidate_events
741
+ WHERE (
742
+ SELECT MAX(deliveries.processed_at)
743
+ FROM #{quoted_delivery_table} deliveries
744
+ WHERE deliveries.event_id = candidate_events.id
745
+ AND deliveries.status IN ('processed', 'superseded')
746
+ ) < (CURRENT_TIMESTAMP - interval '#{retention_seconds} seconds')
747
+ ),
748
+ aggregate AS (
749
+ #{event_status_aggregate_sql('SELECT id FROM eligible_events')}
750
+ ),
751
+ updated_events AS (
752
+ UPDATE #{quoted_table} events
753
+ SET status = aggregate.status,
754
+ processed_at = #{aggregate_processed_at_sql},
755
+ last_error = aggregate.last_error,
756
+ updated_at = CURRENT_TIMESTAMP
757
+ FROM aggregate
758
+ WHERE events.id = aggregate.event_id
759
+ RETURNING events.id
760
+ )
761
+ SELECT id
762
+ FROM updated_events
763
+ SQL
764
+ end
765
+
766
+ def aggregate_processed_at_sql
767
+ "CASE WHEN aggregate.status IN ('processed', 'superseded') " \
768
+ 'THEN COALESCE(aggregate.terminal_processed_at, CURRENT_TIMESTAMP) ELSE NULL END'
769
+ end
770
+
678
771
  def select_rows(sql)
679
772
  result = connection.select_all(sql)
680
773
  return result.to_a if result.respond_to?(:to_a)
@@ -832,6 +925,15 @@ module SearchEngine
832
925
  SearchEngine.config.postgres_outbox.drain_slot_table_name
833
926
  end
834
927
 
928
+ def delivery_table_exists?
929
+ table_name = SearchEngine.config.postgres_outbox.delivery_table_name
930
+ if connection.respond_to?(:data_source_exists?)
931
+ connection.data_source_exists?(table_name)
932
+ else
933
+ connection.table_exists?(table_name)
934
+ end
935
+ end
936
+
835
937
  def quote(value)
836
938
  connection.quote(value)
837
939
  end
@@ -3,5 +3,5 @@
3
3
  module SearchEngine
4
4
  # Current gem version.
5
5
  # @return [String]
6
- VERSION = '30.1.8.18'
6
+ VERSION = '30.1.8.19'
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: search-engine-for-typesense
3
3
  version: !ruby/object:Gem::Version
4
- version: 30.1.8.18
4
+ version: 30.1.8.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikita Shkoda
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-08 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby