source_monitor 0.1.1

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 (202) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +16 -0
  3. data/.rubocop.yml +12 -0
  4. data/.ruby-version +1 -0
  5. data/AGENTS.md +132 -0
  6. data/CHANGELOG.md +66 -0
  7. data/CONTRIBUTING.md +31 -0
  8. data/Gemfile +30 -0
  9. data/Gemfile.lock +411 -0
  10. data/MIT-LICENSE +20 -0
  11. data/README.md +108 -0
  12. data/Rakefile +8 -0
  13. data/app/assets/builds/.keep +0 -0
  14. data/app/assets/config/source_monitor_manifest.js +4 -0
  15. data/app/assets/images/source_monitor/.keep +0 -0
  16. data/app/assets/javascripts/source_monitor/application.js +20 -0
  17. data/app/assets/javascripts/source_monitor/controllers/async_submit_controller.js +36 -0
  18. data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +109 -0
  19. data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
  20. data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +53 -0
  21. data/app/assets/javascripts/source_monitor/turbo_actions.js +13 -0
  22. data/app/assets/stylesheets/source_monitor/application.tailwind.css +13 -0
  23. data/app/assets/svgs/source_monitor/.keep +0 -0
  24. data/app/controllers/concerns/.keep +0 -0
  25. data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +81 -0
  26. data/app/controllers/source_monitor/application_controller.rb +62 -0
  27. data/app/controllers/source_monitor/dashboard_controller.rb +27 -0
  28. data/app/controllers/source_monitor/fetch_logs_controller.rb +9 -0
  29. data/app/controllers/source_monitor/health_controller.rb +10 -0
  30. data/app/controllers/source_monitor/items_controller.rb +116 -0
  31. data/app/controllers/source_monitor/logs_controller.rb +15 -0
  32. data/app/controllers/source_monitor/scrape_logs_controller.rb +9 -0
  33. data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +35 -0
  34. data/app/controllers/source_monitor/source_fetches_controller.rb +22 -0
  35. data/app/controllers/source_monitor/source_health_checks_controller.rb +34 -0
  36. data/app/controllers/source_monitor/source_health_resets_controller.rb +27 -0
  37. data/app/controllers/source_monitor/source_retries_controller.rb +22 -0
  38. data/app/controllers/source_monitor/source_turbo_responses.rb +115 -0
  39. data/app/controllers/source_monitor/sources_controller.rb +179 -0
  40. data/app/helpers/source_monitor/application_helper.rb +327 -0
  41. data/app/jobs/source_monitor/application_job.rb +13 -0
  42. data/app/jobs/source_monitor/fetch_feed_job.rb +117 -0
  43. data/app/jobs/source_monitor/item_cleanup_job.rb +48 -0
  44. data/app/jobs/source_monitor/log_cleanup_job.rb +47 -0
  45. data/app/jobs/source_monitor/schedule_fetches_job.rb +29 -0
  46. data/app/jobs/source_monitor/scrape_item_job.rb +47 -0
  47. data/app/jobs/source_monitor/source_health_check_job.rb +77 -0
  48. data/app/mailers/source_monitor/application_mailer.rb +17 -0
  49. data/app/models/concerns/.keep +0 -0
  50. data/app/models/concerns/source_monitor/loggable.rb +18 -0
  51. data/app/models/source_monitor/application_record.rb +5 -0
  52. data/app/models/source_monitor/fetch_log.rb +31 -0
  53. data/app/models/source_monitor/health_check_log.rb +28 -0
  54. data/app/models/source_monitor/item.rb +102 -0
  55. data/app/models/source_monitor/item_content.rb +11 -0
  56. data/app/models/source_monitor/log_entry.rb +56 -0
  57. data/app/models/source_monitor/scrape_log.rb +31 -0
  58. data/app/models/source_monitor/source.rb +115 -0
  59. data/app/views/layouts/source_monitor/application.html.erb +54 -0
  60. data/app/views/source_monitor/dashboard/_fetch_schedule.html.erb +90 -0
  61. data/app/views/source_monitor/dashboard/_job_metrics.html.erb +82 -0
  62. data/app/views/source_monitor/dashboard/_recent_activity.html.erb +39 -0
  63. data/app/views/source_monitor/dashboard/_stat_card.html.erb +6 -0
  64. data/app/views/source_monitor/dashboard/_stats.html.erb +9 -0
  65. data/app/views/source_monitor/dashboard/index.html.erb +48 -0
  66. data/app/views/source_monitor/fetch_logs/show.html.erb +90 -0
  67. data/app/views/source_monitor/items/_details.html.erb +234 -0
  68. data/app/views/source_monitor/items/_details_wrapper.html.erb +3 -0
  69. data/app/views/source_monitor/items/index.html.erb +147 -0
  70. data/app/views/source_monitor/items/show.html.erb +3 -0
  71. data/app/views/source_monitor/logs/index.html.erb +208 -0
  72. data/app/views/source_monitor/scrape_logs/show.html.erb +73 -0
  73. data/app/views/source_monitor/shared/_toast.html.erb +34 -0
  74. data/app/views/source_monitor/sources/_bulk_scrape_form.html.erb +64 -0
  75. data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +53 -0
  76. data/app/views/source_monitor/sources/_details.html.erb +302 -0
  77. data/app/views/source_monitor/sources/_details_wrapper.html.erb +3 -0
  78. data/app/views/source_monitor/sources/_empty_state_row.html.erb +5 -0
  79. data/app/views/source_monitor/sources/_fetch_interval_heatmap.html.erb +46 -0
  80. data/app/views/source_monitor/sources/_form.html.erb +143 -0
  81. data/app/views/source_monitor/sources/_health_status_badge.html.erb +46 -0
  82. data/app/views/source_monitor/sources/_row.html.erb +102 -0
  83. data/app/views/source_monitor/sources/edit.html.erb +28 -0
  84. data/app/views/source_monitor/sources/index.html.erb +153 -0
  85. data/app/views/source_monitor/sources/new.html.erb +22 -0
  86. data/app/views/source_monitor/sources/show.html.erb +3 -0
  87. data/config/coverage_baseline.json +2010 -0
  88. data/config/initializers/feedjira.rb +19 -0
  89. data/config/routes.rb +18 -0
  90. data/config/tailwind.config.js +17 -0
  91. data/db/migrate/20241008120000_create_source_monitor_sources.rb +40 -0
  92. data/db/migrate/20241008121000_create_source_monitor_items.rb +44 -0
  93. data/db/migrate/20241008122000_create_source_monitor_fetch_logs.rb +32 -0
  94. data/db/migrate/20241008123000_create_source_monitor_scrape_logs.rb +25 -0
  95. data/db/migrate/20251008183000_change_fetch_interval_to_minutes.rb +23 -0
  96. data/db/migrate/20251009090000_create_source_monitor_item_contents.rb +38 -0
  97. data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +5 -0
  98. data/db/migrate/20251010090000_add_adaptive_fetching_toggle_to_sources.rb +7 -0
  99. data/db/migrate/20251010123000_add_deleted_at_to_source_monitor_items.rb +8 -0
  100. data/db/migrate/20251010153000_add_type_to_source_monitor_sources.rb +8 -0
  101. data/db/migrate/20251010154500_add_fetch_status_to_source_monitor_sources.rb +9 -0
  102. data/db/migrate/20251010160000_create_solid_cable_messages.rb +16 -0
  103. data/db/migrate/20251011090000_add_fetch_retry_state_to_sources.rb +14 -0
  104. data/db/migrate/20251012090000_add_health_fields_to_sources.rb +17 -0
  105. data/db/migrate/20251012100000_optimize_source_monitor_database_performance.rb +13 -0
  106. data/db/migrate/20251014064947_add_not_null_constraints_to_items.rb +30 -0
  107. data/db/migrate/20251014171659_add_performance_indexes.rb +29 -0
  108. data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +18 -0
  109. data/db/migrate/20251015100000_create_source_monitor_log_entries.rb +89 -0
  110. data/db/migrate/20251022100000_create_source_monitor_health_check_logs.rb +22 -0
  111. data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +29 -0
  112. data/docs/configuration.md +170 -0
  113. data/docs/deployment.md +63 -0
  114. data/docs/gh-cli-workflow.md +44 -0
  115. data/docs/installation.md +144 -0
  116. data/docs/troubleshooting.md +76 -0
  117. data/eslint.config.mjs +27 -0
  118. data/lib/generators/source_monitor/install/install_generator.rb +59 -0
  119. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +155 -0
  120. data/lib/source_monitor/analytics/source_activity_rates.rb +53 -0
  121. data/lib/source_monitor/analytics/source_fetch_interval_distribution.rb +57 -0
  122. data/lib/source_monitor/analytics/sources_index_metrics.rb +92 -0
  123. data/lib/source_monitor/assets/bundler.rb +49 -0
  124. data/lib/source_monitor/assets.rb +6 -0
  125. data/lib/source_monitor/configuration.rb +654 -0
  126. data/lib/source_monitor/dashboard/queries.rb +356 -0
  127. data/lib/source_monitor/dashboard/quick_action.rb +7 -0
  128. data/lib/source_monitor/dashboard/quick_actions_presenter.rb +26 -0
  129. data/lib/source_monitor/dashboard/recent_activity.rb +30 -0
  130. data/lib/source_monitor/dashboard/recent_activity_presenter.rb +77 -0
  131. data/lib/source_monitor/dashboard/turbo_broadcaster.rb +87 -0
  132. data/lib/source_monitor/dashboard/upcoming_fetch_schedule.rb +126 -0
  133. data/lib/source_monitor/engine.rb +107 -0
  134. data/lib/source_monitor/events.rb +110 -0
  135. data/lib/source_monitor/feedjira_extensions.rb +103 -0
  136. data/lib/source_monitor/fetching/advisory_lock.rb +54 -0
  137. data/lib/source_monitor/fetching/completion/event_publisher.rb +22 -0
  138. data/lib/source_monitor/fetching/completion/follow_up_handler.rb +37 -0
  139. data/lib/source_monitor/fetching/completion/retention_handler.rb +30 -0
  140. data/lib/source_monitor/fetching/feed_fetcher.rb +627 -0
  141. data/lib/source_monitor/fetching/fetch_error.rb +88 -0
  142. data/lib/source_monitor/fetching/fetch_runner.rb +142 -0
  143. data/lib/source_monitor/fetching/retry_policy.rb +85 -0
  144. data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +146 -0
  145. data/lib/source_monitor/health/source_health_check.rb +100 -0
  146. data/lib/source_monitor/health/source_health_monitor.rb +210 -0
  147. data/lib/source_monitor/health/source_health_reset.rb +68 -0
  148. data/lib/source_monitor/health.rb +46 -0
  149. data/lib/source_monitor/http.rb +85 -0
  150. data/lib/source_monitor/instrumentation.rb +52 -0
  151. data/lib/source_monitor/items/item_creator.rb +601 -0
  152. data/lib/source_monitor/items/retention_pruner.rb +146 -0
  153. data/lib/source_monitor/items/retention_strategies/destroy.rb +26 -0
  154. data/lib/source_monitor/items/retention_strategies/soft_delete.rb +50 -0
  155. data/lib/source_monitor/items/retention_strategies.rb +9 -0
  156. data/lib/source_monitor/jobs/cleanup_options.rb +85 -0
  157. data/lib/source_monitor/jobs/fetch_failure_subscriber.rb +129 -0
  158. data/lib/source_monitor/jobs/solid_queue_metrics.rb +199 -0
  159. data/lib/source_monitor/jobs/visibility.rb +133 -0
  160. data/lib/source_monitor/logs/entry_sync.rb +69 -0
  161. data/lib/source_monitor/logs/filter_set.rb +163 -0
  162. data/lib/source_monitor/logs/query.rb +81 -0
  163. data/lib/source_monitor/logs/table_presenter.rb +161 -0
  164. data/lib/source_monitor/metrics.rb +77 -0
  165. data/lib/source_monitor/model_extensions.rb +109 -0
  166. data/lib/source_monitor/models/sanitizable.rb +76 -0
  167. data/lib/source_monitor/models/url_normalizable.rb +84 -0
  168. data/lib/source_monitor/pagination/paginator.rb +90 -0
  169. data/lib/source_monitor/realtime/adapter.rb +97 -0
  170. data/lib/source_monitor/realtime/broadcaster.rb +237 -0
  171. data/lib/source_monitor/realtime.rb +17 -0
  172. data/lib/source_monitor/release/changelog.rb +59 -0
  173. data/lib/source_monitor/release/runner.rb +73 -0
  174. data/lib/source_monitor/scheduler.rb +82 -0
  175. data/lib/source_monitor/scrapers/base.rb +105 -0
  176. data/lib/source_monitor/scrapers/fetchers/http_fetcher.rb +97 -0
  177. data/lib/source_monitor/scrapers/parsers/readability_parser.rb +101 -0
  178. data/lib/source_monitor/scrapers/readability.rb +156 -0
  179. data/lib/source_monitor/scraping/bulk_result_presenter.rb +85 -0
  180. data/lib/source_monitor/scraping/bulk_source_scraper.rb +233 -0
  181. data/lib/source_monitor/scraping/enqueuer.rb +125 -0
  182. data/lib/source_monitor/scraping/item_scraper/adapter_resolver.rb +44 -0
  183. data/lib/source_monitor/scraping/item_scraper/persistence.rb +189 -0
  184. data/lib/source_monitor/scraping/item_scraper.rb +84 -0
  185. data/lib/source_monitor/scraping/scheduler.rb +43 -0
  186. data/lib/source_monitor/scraping/state.rb +79 -0
  187. data/lib/source_monitor/security/authentication.rb +85 -0
  188. data/lib/source_monitor/security/parameter_sanitizer.rb +42 -0
  189. data/lib/source_monitor/sources/turbo_stream_presenter.rb +54 -0
  190. data/lib/source_monitor/turbo_streams/stream_responder.rb +95 -0
  191. data/lib/source_monitor/version.rb +3 -0
  192. data/lib/source_monitor.rb +149 -0
  193. data/lib/tasks/recover_stalled_fetches.rake +16 -0
  194. data/lib/tasks/source_monitor_assets.rake +28 -0
  195. data/lib/tasks/source_monitor_tasks.rake +29 -0
  196. data/lib/tasks/test_smoke.rake +12 -0
  197. data/package-lock.json +3997 -0
  198. data/package.json +29 -0
  199. data/postcss.config.js +6 -0
  200. data/source_monitor.gemspec +46 -0
  201. data/stylelint.config.js +12 -0
  202. metadata +469 -0
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "source_monitor/instrumentation"
4
+ require "source_monitor/items/retention_strategies"
5
+ require "source_monitor/items/retention_strategies/destroy"
6
+ require "source_monitor/items/retention_strategies/soft_delete"
7
+
8
+ module SourceMonitor
9
+ module Items
10
+ # Removes items that fall outside the configured retention rules for a source.
11
+ # Supports age-based (items_retention_days) and count-based (max_items) limits.
12
+ class RetentionPruner
13
+ Result = Struct.new(:removed_by_age, :removed_by_limit, :removed_total, keyword_init: true) do
14
+ def applied?
15
+ removed_total.positive?
16
+ end
17
+ end
18
+
19
+ STRATEGY_CLASSES = {
20
+ destroy: SourceMonitor::Items::RetentionStrategies::Destroy,
21
+ soft_delete: SourceMonitor::Items::RetentionStrategies::SoftDelete
22
+ }.freeze
23
+
24
+ def self.call(source:, now: Time.current, strategy: nil)
25
+ new(source:, now:, strategy:).call
26
+ end
27
+
28
+ def initialize(source:, now: Time.current, strategy: nil)
29
+ @source = source
30
+ @now = now
31
+ @strategy_name = normalize_strategy(strategy)
32
+ @strategy_handler = STRATEGY_CLASSES.fetch(@strategy_name).new(source: source)
33
+ end
34
+
35
+ def call
36
+ removed_by_age = prune_by_age
37
+ removed_by_limit = prune_by_limit
38
+ removed_total = removed_by_age + removed_by_limit
39
+
40
+ if removed_total.positive?
41
+ SourceMonitor::Instrumentation.item_retention(
42
+ source_id: source.id,
43
+ removed_by_age:,
44
+ removed_by_limit:,
45
+ removed_total:,
46
+ items_retention_days: items_retention_days,
47
+ max_items: max_items_limit
48
+ )
49
+ end
50
+
51
+ Result.new(
52
+ removed_by_age:,
53
+ removed_by_limit:,
54
+ removed_total:
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :source, :now, :strategy_name, :strategy_handler
61
+
62
+ def prune_by_age
63
+ days = items_retention_days
64
+ return 0 unless days.present?
65
+
66
+ return 0 if days <= 0
67
+
68
+ cutoff = now - days.days
69
+
70
+ timestamp_expression = Arel::Nodes::NamedFunction.new(
71
+ "COALESCE",
72
+ [
73
+ SourceMonitor::Item.arel_table[:published_at],
74
+ SourceMonitor::Item.arel_table[:created_at]
75
+ ]
76
+ )
77
+
78
+ scope = source.items.where(timestamp_expression.lteq(cutoff))
79
+ remove_scope(scope)
80
+ end
81
+
82
+ def prune_by_limit
83
+ limit = max_items_limit
84
+ return 0 unless limit.present?
85
+
86
+ return 0 if limit <= 0
87
+
88
+ ids_to_keep = source.items
89
+ .order(Arel.sql("published_at DESC NULLS LAST, created_at DESC"))
90
+ .limit(limit)
91
+ .pluck(:id)
92
+
93
+ scope =
94
+ if ids_to_keep.empty?
95
+ source.items.none
96
+ else
97
+ source.items.where.not(id: ids_to_keep)
98
+ end
99
+
100
+ remove_scope(scope)
101
+ end
102
+
103
+ def remove_scope(scope)
104
+ return 0 if scope.none?
105
+
106
+ removed = 0
107
+ scope.in_batches(of: 100) do |batch|
108
+ removed += strategy_handler.apply(batch:, now:)
109
+ end
110
+ removed
111
+ end
112
+
113
+ def normalize_strategy(value)
114
+ value = SourceMonitor.config.retention.strategy if value.nil?
115
+
116
+ value = value.to_sym if value.respond_to?(:to_sym)
117
+ return value if STRATEGY_CLASSES.key?(value)
118
+
119
+ valid = STRATEGY_CLASSES.keys.join(", ")
120
+ raise ArgumentError, "Invalid retention strategy #{value.inspect}. Valid strategies: #{valid}"
121
+ end
122
+
123
+ def items_retention_days
124
+ @items_retention_days ||= begin
125
+ value = source.items_retention_days
126
+ value = SourceMonitor.config.retention.items_retention_days if value.nil?
127
+ convert_to_integer(value)
128
+ end
129
+ end
130
+
131
+ def max_items_limit
132
+ @max_items_limit ||= begin
133
+ value = source.max_items
134
+ value = SourceMonitor.config.retention.max_items if value.nil?
135
+ convert_to_integer(value)
136
+ end
137
+ end
138
+
139
+ def convert_to_integer(value)
140
+ return nil if value.nil?
141
+
142
+ value.respond_to?(:to_i) ? value.to_i : value
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Items
5
+ module RetentionStrategies
6
+ class Destroy
7
+ def initialize(source:)
8
+ @source = source
9
+ end
10
+
11
+ def apply(batch:, now: Time.current) # rubocop:disable Lint/UnusedMethodArgument
12
+ count = 0
13
+ batch.each do |item|
14
+ item.destroy!
15
+ count += 1
16
+ end
17
+ count
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :source
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Items
5
+ module RetentionStrategies
6
+ class SoftDelete
7
+ def initialize(source:)
8
+ @source = source
9
+ end
10
+
11
+ def apply(batch:, now: Time.current)
12
+ ids = Array(batch.pluck(:id))
13
+ return 0 if ids.empty?
14
+
15
+ timestamp = normalized_timestamp(now)
16
+
17
+ # Use with_deleted to update items that may already be marked as deleted
18
+ SourceMonitor::Item.with_deleted.where(id: ids).update_all(
19
+ deleted_at: timestamp,
20
+ updated_at: timestamp
21
+ )
22
+
23
+ adjust_source_counter(ids.length)
24
+ ids.length
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :source
30
+
31
+ def normalized_timestamp(now)
32
+ return Time.current if now.nil?
33
+
34
+ now.respond_to?(:in_time_zone) ? now.in_time_zone : now
35
+ end
36
+
37
+ def adjust_source_counter(amount)
38
+ return unless source&.id
39
+
40
+ SourceMonitor::Source.update_counters(source.id, items_count: -amount)
41
+
42
+ return unless source.respond_to?(:items_count) && !source.items_count.nil?
43
+
44
+ source.items_count -= amount
45
+ source.items_count = 0 if source.items_count.negative?
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Items
5
+ module RetentionStrategies
6
+ # Namespace for item retention strategy implementations.
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module SourceMonitor
6
+ module Jobs
7
+ module CleanupOptions
8
+ module_function
9
+
10
+ def normalize(options)
11
+ case options
12
+ when nil
13
+ {}
14
+ when Hash
15
+ options.respond_to?(:symbolize_keys) ? options.symbolize_keys : symbolize_keys(options)
16
+ else
17
+ {}
18
+ end
19
+ end
20
+
21
+ def resolve_time(value, default: Time.current)
22
+ case value
23
+ when nil
24
+ default
25
+ when Time
26
+ value
27
+ when String
28
+ parse_time(value, default)
29
+ else
30
+ value.respond_to?(:to_time) ? value.to_time : default
31
+ end
32
+ end
33
+
34
+ def extract_ids(value)
35
+ Array(value)
36
+ .flat_map do |entry|
37
+ case entry
38
+ when Integer
39
+ [ entry ]
40
+ when Array
41
+ entry
42
+ else
43
+ entry.to_s.split(",")
44
+ end
45
+ end
46
+ .map { |entry| entry.is_a?(String) ? entry.strip : entry }
47
+ .reject { |entry| entry.respond_to?(:blank?) ? entry.blank? : entry.nil? }
48
+ .map { |entry| integer(entry) }
49
+ .compact
50
+ .reject(&:zero?)
51
+ end
52
+
53
+ def integer(value)
54
+ return value if value.is_a?(Integer)
55
+
56
+ Integer(value, exception: false)
57
+ end
58
+
59
+ def batch_size(options, default:)
60
+ value = integer(options[:batch_size])
61
+ return default unless value&.positive?
62
+
63
+ value
64
+ end
65
+
66
+ def symbolize_keys(hash)
67
+ hash.each_with_object({}) do |(key, value), memo|
68
+ memo[key.respond_to?(:to_sym) ? key.to_sym : key] = value
69
+ end
70
+ end
71
+ private_class_method :symbolize_keys
72
+
73
+ def parse_time(value, default)
74
+ if Time.zone
75
+ Time.zone.parse(value) || default
76
+ else
77
+ Time.parse(value)
78
+ end
79
+ rescue ArgumentError, TypeError
80
+ default
81
+ end
82
+ private_class_method :parse_time
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Jobs
5
+ module FetchFailureCallbacks
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ after_create :notify_source_monitor_of_failure
10
+ end
11
+
12
+ private
13
+
14
+ def notify_source_monitor_of_failure
15
+ SourceMonitor::Jobs::FetchFailureSubscriber.handle_failed_execution(self)
16
+ end
17
+ end
18
+
19
+ class FetchFailureSubscriber
20
+ PROCESS_FAILURE_CLASSES = %w[
21
+ SolidQueue::Processes::ProcessExitError
22
+ SolidQueue::Processes::ProcessPrunedError
23
+ ].freeze
24
+
25
+ class << self
26
+ def setup!
27
+ register_on_load_hook!
28
+ attach_callbacks! if solid_queue_loaded?
29
+ end
30
+
31
+ def handle_failed_execution(failed_execution)
32
+ job = failed_execution.job
33
+ return unless job
34
+ return unless job.queue_name == SourceMonitor.queue_name(:fetch)
35
+
36
+ error = failed_execution.error || {}
37
+ return unless PROCESS_FAILURE_CLASSES.include?(error["exception_class"])
38
+
39
+ source_id = extract_source_id(job)
40
+ return unless source_id
41
+
42
+ source = SourceMonitor::Source.find_by(id: source_id)
43
+ return unless source
44
+
45
+ now = Time.current
46
+
47
+ source.with_lock do
48
+ source.reload
49
+ update_attributes = {
50
+ fetch_status: "failed",
51
+ last_error: formatted_error_message(error),
52
+ last_error_at: now,
53
+ failure_count: source.failure_count.to_i + 1,
54
+ next_fetch_at: now,
55
+ backoff_until: nil
56
+ }
57
+ source.update!(update_attributes)
58
+ end
59
+
60
+ SourceMonitor::Realtime.broadcast_source(source)
61
+ rescue StandardError => exception
62
+ log_failure(source_id, exception)
63
+ end
64
+
65
+ def attach_callbacks!
66
+ failed_execution_class = load_failed_execution_class
67
+ return unless failed_execution_class
68
+ return if failed_execution_class < FetchFailureCallbacks
69
+
70
+ failed_execution_class.include(FetchFailureCallbacks)
71
+ end
72
+
73
+ private
74
+
75
+ def solid_queue_loaded?
76
+ !!load_failed_execution_class
77
+ end
78
+
79
+ def register_on_load_hook!
80
+ return if @hook_registered
81
+
82
+ ActiveSupport.on_load(:solid_queue) do
83
+ SourceMonitor::Jobs::FetchFailureSubscriber.attach_callbacks!
84
+ end
85
+
86
+ @hook_registered = true
87
+ end
88
+
89
+ def load_failed_execution_class
90
+ ::SolidQueue::FailedExecution
91
+ rescue NameError
92
+ begin
93
+ require "solid_queue/failed_execution"
94
+ rescue LoadError
95
+ return nil
96
+ end
97
+
98
+ ::SolidQueue::FailedExecution
99
+ end
100
+
101
+ def extract_source_id(job)
102
+ arguments = job.arguments
103
+ return unless arguments.is_a?(Hash)
104
+
105
+ args_array = arguments["arguments"]
106
+ return unless args_array.is_a?(Array)
107
+
108
+ source_arg = args_array.first
109
+ Integer(source_arg)
110
+ rescue ArgumentError, TypeError
111
+ nil
112
+ end
113
+
114
+ def formatted_error_message(error)
115
+ message = error["message"] || "Fetch job terminated unexpectedly"
116
+ "#{error['exception_class']}: #{message}"
117
+ end
118
+
119
+ def log_failure(source_id, exception)
120
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
121
+
122
+ Rails.logger.error(
123
+ "[SourceMonitor::Jobs::FetchFailureSubscriber] Failed to handle process failure for source #{source_id.inspect}: #{exception.class}: #{exception.message}"
124
+ )
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Jobs
5
+ class SolidQueueMetrics
6
+ QueueSummary = Struct.new(
7
+ :queue_name,
8
+ :ready_count,
9
+ :scheduled_count,
10
+ :failed_count,
11
+ :recurring_count,
12
+ :paused,
13
+ :last_enqueued_at,
14
+ :last_started_at,
15
+ :last_finished_at,
16
+ :available,
17
+ keyword_init: true
18
+ ) do
19
+ def total_count
20
+ ready_count + scheduled_count + failed_count
21
+ end
22
+ end
23
+
24
+ DEFAULT_QUEUE_NAME = "default"
25
+
26
+ def self.call(queue_names:)
27
+ new(queue_names).call
28
+ end
29
+
30
+ def initialize(queue_names)
31
+ @queue_names = Array(queue_names).map(&:to_s)
32
+ end
33
+
34
+ def call
35
+ metrics = initialize_metrics
36
+
37
+ return metrics unless solid_queue_supported?
38
+
39
+ populate_counts(metrics)
40
+ populate_timestamps(metrics)
41
+ populate_pause_state(metrics)
42
+
43
+ metrics
44
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
45
+ @solid_queue_supported = false
46
+ initialize_metrics
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :queue_names
52
+
53
+ def initialize_metrics
54
+ queue_names.index_with do |queue_name|
55
+ QueueSummary.new(
56
+ queue_name: queue_name,
57
+ ready_count: 0,
58
+ scheduled_count: 0,
59
+ failed_count: 0,
60
+ recurring_count: 0,
61
+ paused: false,
62
+ last_enqueued_at: nil,
63
+ last_started_at: nil,
64
+ last_finished_at: nil,
65
+ available: solid_queue_supported?
66
+ )
67
+ end
68
+ end
69
+
70
+ def solid_queue_supported?
71
+ return @solid_queue_supported if defined?(@solid_queue_supported)
72
+
73
+ @solid_queue_supported =
74
+ defined?(::SolidQueue::Job) &&
75
+ defined?(::SolidQueue::ReadyExecution) &&
76
+ defined?(::SolidQueue::ScheduledExecution) &&
77
+ table_exists?(::SolidQueue::Job) &&
78
+ table_exists?(::SolidQueue::ReadyExecution) &&
79
+ table_exists?(::SolidQueue::ScheduledExecution)
80
+ end
81
+
82
+ def populate_counts(metrics)
83
+ merge_counts(metrics, ready_counts, :ready_count)
84
+ merge_counts(metrics, scheduled_counts, :scheduled_count)
85
+ merge_counts(metrics, failed_counts, :failed_count)
86
+ merge_counts(metrics, recurring_counts, :recurring_count)
87
+ end
88
+
89
+ def populate_timestamps(metrics)
90
+ merge_counts(metrics, last_enqueued_times, :last_enqueued_at)
91
+ merge_counts(metrics, last_started_times, :last_started_at)
92
+ merge_counts(metrics, last_finished_times, :last_finished_at)
93
+ end
94
+
95
+ def populate_pause_state(metrics)
96
+ return unless table_exists?(::SolidQueue::Pause)
97
+
98
+ paused_queue_names.each do |queue_name|
99
+ summary = metrics[queue_name]
100
+ next unless summary
101
+
102
+ summary.paused = true
103
+ end
104
+ end
105
+
106
+ def merge_counts(metrics, collection, attribute)
107
+ collection.each do |queue_name, value|
108
+ summary = metrics[queue_name.to_s]
109
+ next unless summary
110
+
111
+ summary.public_send("#{attribute}=", value)
112
+ end
113
+ end
114
+
115
+ def ready_counts
116
+ return {} unless table_exists?(::SolidQueue::ReadyExecution)
117
+
118
+ ::SolidQueue::ReadyExecution.
119
+ where(queue_name: queue_names).
120
+ group(:queue_name).
121
+ count
122
+ end
123
+
124
+ def scheduled_counts
125
+ return {} unless table_exists?(::SolidQueue::ScheduledExecution)
126
+
127
+ ::SolidQueue::ScheduledExecution.
128
+ where(queue_name: queue_names).
129
+ group(:queue_name).
130
+ count
131
+ end
132
+
133
+ def failed_counts
134
+ return {} unless defined?(::SolidQueue::FailedExecution) && table_exists?(::SolidQueue::FailedExecution)
135
+
136
+ ::SolidQueue::FailedExecution.
137
+ joins(:job).
138
+ where(::SolidQueue::Job.arel_table[:queue_name].in(queue_names)).
139
+ group(::SolidQueue::Job.arel_table[:queue_name]).
140
+ count
141
+ end
142
+
143
+ def recurring_counts
144
+ return {} unless defined?(::SolidQueue::RecurringTask) && table_exists?(::SolidQueue::RecurringTask)
145
+
146
+ ::SolidQueue::RecurringTask.
147
+ group(:queue_name).
148
+ count.
149
+ transform_keys { |queue_name| normalize_queue_name(queue_name) }.
150
+ select { |queue_name, _| queue_names.include?(queue_name) }
151
+ end
152
+
153
+ def last_enqueued_times
154
+ ::SolidQueue::Job.
155
+ where(queue_name: queue_names).
156
+ group(:queue_name).
157
+ maximum(:created_at)
158
+ end
159
+
160
+ def last_started_times
161
+ return {} unless defined?(::SolidQueue::ClaimedExecution) && table_exists?(::SolidQueue::ClaimedExecution)
162
+
163
+ ::SolidQueue::ClaimedExecution.
164
+ joins(:job).
165
+ where(::SolidQueue::Job.arel_table[:queue_name].in(queue_names)).
166
+ group(::SolidQueue::Job.arel_table[:queue_name]).
167
+ maximum(arel_table_for(::SolidQueue::ClaimedExecution)[:created_at])
168
+ end
169
+
170
+ def last_finished_times
171
+ ::SolidQueue::Job.
172
+ where(queue_name: queue_names).
173
+ where.not(finished_at: nil).
174
+ group(:queue_name).
175
+ maximum(:finished_at)
176
+ end
177
+
178
+ def paused_queue_names
179
+ ::SolidQueue::Pause.
180
+ where(queue_name: queue_names).
181
+ pluck(:queue_name)
182
+ end
183
+
184
+ def table_exists?(model)
185
+ model.respond_to?(:table_exists?) && model.table_exists?
186
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
187
+ false
188
+ end
189
+
190
+ def normalize_queue_name(name)
191
+ (name.presence || DEFAULT_QUEUE_NAME).to_s
192
+ end
193
+
194
+ def arel_table_for(model)
195
+ model.arel_table
196
+ end
197
+ end
198
+ end
199
+ end