search-engine-for-typesense 30.1.8.17 → 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: 3a268323694adc2817c238b7ae9f522a7685b55b240691fb5891a98b4bc3d371
4
- data.tar.gz: 3fcdddf6f9bdd81ecca5aae6755e3d55f7d5a9fd52e50ed3ed2d5bbdaa163b53
3
+ metadata.gz: 20f330c7ec29b4f4ea6366c48d23f05d5a089e47eaeba79f0ad741c2a83b09ec
4
+ data.tar.gz: 1359ad4c68d304d334c238bae7beddeee57a6350565b8b8e5b6b8d3f97c75e82
5
5
  SHA512:
6
- metadata.gz: 88f09c6d71f2a6c91505b8d83e57b6e989de2f8fbbc477b7b35185996da5cb0eae918dcf3ed14f8c9f3c69760469b5da9d32da2336b5cd640bffc386e31869b0
7
- data.tar.gz: ab29408bb29a73d210aa29c3db62bb3bf684b8105a6dbe5aaf918ed299f8849b3eadb8ecb406d30db11e2353941aafb5ec8cb58ae4c180ca684ad720a5a55697
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)).
@@ -42,6 +42,9 @@ module SearchEngine
42
42
  add_index table_name,
43
43
  %i[collection document_id status id],
44
44
  name: 'idx_search_engine_outbox_coalescing'
45
+ add_index table_name,
46
+ %i[collection document_id id],
47
+ name: 'idx_search_engine_outbox_coalesce_lookup'
45
48
  add_index table_name,
46
49
  :locked_at,
47
50
  name: 'idx_search_engine_outbox_processing',
@@ -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
 
@@ -470,26 +501,33 @@ module SearchEngine
470
501
 
471
502
  def materialization_supersede_older_deliveries_sql(rows, targets)
472
503
  <<~SQL
473
- WITH updated_deliveries AS (
504
+ WITH latest(collection, document_id, id) AS (
505
+ VALUES #{coalesce_values_sql(rows)}
506
+ ),
507
+ target(target_key, queue_name) AS (
508
+ VALUES #{delivery_target_values_sql(targets)}
509
+ ),
510
+ older_event_targets AS MATERIALIZED (
511
+ SELECT older_events.id AS event_id,
512
+ target.target_key
513
+ FROM latest
514
+ CROSS JOIN target
515
+ INNER JOIN #{quoted_table} older_events
516
+ ON older_events.collection = latest.collection
517
+ AND older_events.document_id = latest.document_id
518
+ AND older_events.id < latest.id
519
+ ),
520
+ updated_deliveries AS (
474
521
  UPDATE #{quoted_delivery_table} older_deliveries
475
522
  SET status = 'superseded',
476
523
  processed_at = CURRENT_TIMESTAMP,
477
524
  locked_at = NULL,
478
525
  locked_by = NULL,
479
526
  updated_at = CURRENT_TIMESTAMP
480
- FROM #{quoted_table} older_events,
481
- (
482
- VALUES #{coalesce_values_sql(rows)}
483
- ) AS latest(collection, document_id, id),
484
- (
485
- VALUES #{delivery_target_values_sql(targets)}
486
- ) AS target(target_key, queue_name)
487
- WHERE older_deliveries.event_id = older_events.id
527
+ FROM older_event_targets
528
+ WHERE older_deliveries.event_id = older_event_targets.event_id
488
529
  AND older_deliveries.status = 'pending'
489
- AND older_deliveries.target_key = target.target_key
490
- AND older_events.collection = latest.collection
491
- AND older_events.document_id = latest.document_id
492
- AND older_events.id < latest.id
530
+ AND older_deliveries.target_key = older_event_targets.target_key
493
531
  RETURNING older_deliveries.event_id
494
532
  ),
495
533
  aggregate AS (
@@ -497,10 +535,7 @@ module SearchEngine
497
535
  )
498
536
  UPDATE #{quoted_table} events
499
537
  SET status = aggregate.status,
500
- processed_at = CASE
501
- WHEN aggregate.status IN ('processed', 'superseded') THEN CURRENT_TIMESTAMP
502
- ELSE NULL
503
- END,
538
+ processed_at = #{aggregate_processed_at_sql},
504
539
  last_error = aggregate.last_error,
505
540
  updated_at = CURRENT_TIMESTAMP
506
541
  FROM aggregate
@@ -528,23 +563,29 @@ module SearchEngine
528
563
 
529
564
  def delivery_supersede_older_pending_sql(rows)
530
565
  <<~SQL
531
- WITH updated_deliveries AS (
566
+ WITH latest(target_key, collection, document_id, event_id, delivery_id) AS (
567
+ VALUES #{delivery_coalesce_values_sql(rows)}
568
+ ),
569
+ older_event_targets AS MATERIALIZED (
570
+ SELECT older_events.id AS event_id,
571
+ latest.target_key
572
+ FROM latest
573
+ INNER JOIN #{quoted_table} older_events
574
+ ON older_events.collection = latest.collection
575
+ AND older_events.document_id = latest.document_id
576
+ AND older_events.id < latest.event_id
577
+ ),
578
+ updated_deliveries AS (
532
579
  UPDATE #{quoted_delivery_table} older_deliveries
533
580
  SET status = 'superseded',
534
581
  processed_at = CURRENT_TIMESTAMP,
535
582
  locked_at = NULL,
536
583
  locked_by = NULL,
537
584
  updated_at = CURRENT_TIMESTAMP
538
- FROM #{quoted_table} older_events,
539
- (
540
- VALUES #{delivery_coalesce_values_sql(rows)}
541
- ) AS latest(target_key, collection, document_id, event_id, delivery_id)
542
- WHERE older_deliveries.event_id = older_events.id
585
+ FROM older_event_targets
586
+ WHERE older_deliveries.event_id = older_event_targets.event_id
543
587
  AND older_deliveries.status = 'pending'
544
- AND older_deliveries.target_key = latest.target_key
545
- AND older_events.collection = latest.collection
546
- AND older_events.document_id = latest.document_id
547
- AND older_events.id < latest.event_id
588
+ AND older_deliveries.target_key = older_event_targets.target_key
548
589
  RETURNING older_deliveries.event_id
549
590
  ),
550
591
  aggregate AS (
@@ -552,10 +593,7 @@ module SearchEngine
552
593
  )
553
594
  UPDATE #{quoted_table} events
554
595
  SET status = aggregate.status,
555
- processed_at = CASE
556
- WHEN aggregate.status IN ('processed', 'superseded') THEN CURRENT_TIMESTAMP
557
- ELSE NULL
558
- END,
596
+ processed_at = #{aggregate_processed_at_sql},
559
597
  last_error = aggregate.last_error,
560
598
  updated_at = CURRENT_TIMESTAMP
561
599
  FROM aggregate
@@ -582,47 +620,60 @@ module SearchEngine
582
620
  ids = Array(event_ids).compact
583
621
  return if ids.empty?
584
622
 
585
- execute(<<~SQL)
586
- UPDATE #{quoted_delivery_table}
587
- SET status = #{quote(status)},
588
- #{extra},
589
- locked_at = NULL,
590
- locked_by = NULL,
591
- updated_at = CURRENT_TIMESTAMP
592
- WHERE target_key = #{quote(target_key)}
593
- AND event_id IN (#{ids_sql(ids)})
594
- SQL
595
- 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
596
635
  end
597
636
 
598
637
  def mark_delivery_retryable!(event_ids, error:)
599
- execute(<<~SQL)
600
- UPDATE #{quoted_delivery_table}
601
- SET attempts = attempts + 1,
602
- status = CASE WHEN attempts + 1 >= #{max_attempts} THEN 'failed' ELSE 'pending' END,
603
- next_attempt_at = CURRENT_TIMESTAMP + #{retry_interval_case_sql},
604
- locked_at = NULL,
605
- locked_by = NULL,
606
- last_error = #{quote(truncate_error(error))},
607
- updated_at = CURRENT_TIMESTAMP
608
- WHERE target_key = #{quote(target_key)}
609
- AND event_id IN (#{ids_sql(event_ids)})
610
- SQL
611
- 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
612
652
  end
613
653
 
614
654
  def mark_delivery_failed!(event_ids, error:)
615
- execute(<<~SQL)
616
- UPDATE #{quoted_delivery_table}
617
- SET status = 'failed',
618
- locked_at = NULL,
619
- locked_by = NULL,
620
- last_error = #{quote(truncate_error(error))},
621
- updated_at = CURRENT_TIMESTAMP
622
- WHERE target_key = #{quote(target_key)}
623
- AND event_id IN (#{ids_sql(event_ids)})
624
- SQL
625
- 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
626
677
  end
627
678
 
628
679
  def refresh_event_statuses!(event_ids)
@@ -632,10 +683,7 @@ module SearchEngine
632
683
  execute(<<~SQL)
633
684
  UPDATE #{quoted_table} events
634
685
  SET status = aggregate.status,
635
- processed_at = CASE
636
- WHEN aggregate.status IN ('processed', 'superseded') THEN CURRENT_TIMESTAMP
637
- ELSE NULL
638
- END,
686
+ processed_at = #{aggregate_processed_at_sql},
639
687
  last_error = aggregate.last_error,
640
688
  updated_at = CURRENT_TIMESTAMP
641
689
  FROM (
@@ -655,6 +703,7 @@ module SearchEngine
655
703
  WHEN COUNT(*) FILTER (WHERE status = 'processed') > 0 THEN 'processed'
656
704
  ELSE 'pending'
657
705
  END AS status,
706
+ MAX(processed_at) FILTER (WHERE status IN ('processed', 'superseded')) AS terminal_processed_at,
658
707
  (ARRAY_AGG(last_error ORDER BY updated_at DESC) FILTER (WHERE last_error IS NOT NULL))[1] AS last_error
659
708
  FROM #{quoted_delivery_table}
660
709
  WHERE event_id IN (#{event_ids_sql})
@@ -662,6 +711,63 @@ module SearchEngine
662
711
  SQL
663
712
  end
664
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
+
665
771
  def select_rows(sql)
666
772
  result = connection.select_all(sql)
667
773
  return result.to_a if result.respond_to?(:to_a)
@@ -819,6 +925,15 @@ module SearchEngine
819
925
  SearchEngine.config.postgres_outbox.drain_slot_table_name
820
926
  end
821
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
+
822
937
  def quote(value)
823
938
  connection.quote(value)
824
939
  end
@@ -3,5 +3,5 @@
3
3
  module SearchEngine
4
4
  # Current gem version.
5
5
  # @return [String]
6
- VERSION = '30.1.8.17'
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.17
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