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,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "source_monitor/fetching/advisory_lock"
4
+ require "source_monitor/fetching/completion/retention_handler"
5
+ require "source_monitor/fetching/completion/follow_up_handler"
6
+ require "source_monitor/fetching/completion/event_publisher"
7
+
8
+ module SourceMonitor
9
+ module Fetching
10
+ # Coordinates execution of FeedFetcher while ensuring we do not run more than
11
+ # one fetch per-source concurrently. The runner also centralizes the logic
12
+ # for queuing follow-up scraping jobs so both background jobs and manual UI
13
+ # entry points share the same behavior.
14
+ class FetchRunner
15
+ LOCK_NAMESPACE = 1_746_219
16
+
17
+ class ConcurrencyError < StandardError; end
18
+
19
+ attr_reader :source, :fetcher_class, :force, :lock, :retention_handler, :follow_up_handler, :event_publisher
20
+
21
+ def initialize(source:, fetcher_class: SourceMonitor::Fetching::FeedFetcher, scrape_job_class: SourceMonitor::ScrapeItemJob, scrape_enqueuer_class: SourceMonitor::Scraping::Enqueuer, retention_pruner_class: SourceMonitor::Items::RetentionPruner, lock_factory: SourceMonitor::Fetching::AdvisoryLock, retention_handler: nil, follow_up_handler: nil, event_publisher: nil, force: false)
22
+ @source = source
23
+ @fetcher_class = fetcher_class
24
+ @force = force
25
+ @lock = lock_factory.new(
26
+ namespace: LOCK_NAMESPACE,
27
+ key: source.id,
28
+ connection_pool: ActiveRecord::Base.connection_pool
29
+ )
30
+ @retention_handler = retention_handler || SourceMonitor::Fetching::Completion::RetentionHandler.new(pruner: retention_pruner_class)
31
+ @follow_up_handler = follow_up_handler || SourceMonitor::Fetching::Completion::FollowUpHandler.new(enqueuer_class: scrape_enqueuer_class, job_class: scrape_job_class)
32
+ @event_publisher = event_publisher || SourceMonitor::Fetching::Completion::EventPublisher.new
33
+ @retry_scheduled = false
34
+ end
35
+
36
+ def self.run(source:, **options)
37
+ new(source:, **options).run
38
+ end
39
+
40
+ def self.enqueue(source_or_id, force: false)
41
+ source = resolve_source(source_or_id)
42
+ return unless source
43
+
44
+ # Don't broadcast here - controller handles immediate UI update
45
+ source.update!(fetch_status: "queued")
46
+ SourceMonitor::FetchFeedJob.perform_later(source.id, force: force)
47
+ end
48
+
49
+ def run
50
+ return skip_due_to_circuit if circuit_blocked?
51
+
52
+ @retry_scheduled = false
53
+ result = nil
54
+
55
+ lock.with_lock do
56
+ mark_fetching!
57
+ result = fetcher_class.new(source: source).call
58
+ retention_handler.call(source:, result:)
59
+ follow_up_handler.call(source:, result:)
60
+ schedule_retry_if_needed(result)
61
+ mark_complete!(result)
62
+ end
63
+
64
+ event_publisher.call(source:, result:)
65
+ result
66
+ rescue SourceMonitor::Fetching::AdvisoryLock::NotAcquiredError => error
67
+ raise ConcurrencyError, error.message
68
+ rescue StandardError => error
69
+ mark_failed!(error)
70
+ event_publisher.call(source:, result: nil)
71
+ raise
72
+ end
73
+
74
+ private
75
+
76
+ def self.resolve_source(source_or_id)
77
+ return source_or_id if source_or_id.is_a?(SourceMonitor::Source)
78
+
79
+ SourceMonitor::Source.find_by(id: source_or_id)
80
+ end
81
+ private_class_method :resolve_source
82
+
83
+ def self.update_source_state!(source, attrs)
84
+ source.update!(attrs)
85
+ SourceMonitor::Realtime.broadcast_source(source)
86
+ rescue StandardError => error
87
+ Rails.logger.error(
88
+ "[SourceMonitor] Failed to update fetch state for source #{source.id}: #{error.class}: #{error.message}"
89
+ ) if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
90
+ end
91
+ private_class_method :update_source_state!
92
+
93
+ def circuit_blocked?
94
+ !force && source.fetch_circuit_open?
95
+ end
96
+
97
+ def skip_due_to_circuit
98
+ update_source_state(fetch_status: "failed")
99
+ event_publisher.call(source:, result: nil)
100
+ nil
101
+ end
102
+
103
+ def mark_fetching!
104
+ update_source_state(fetch_status: "fetching", last_fetch_started_at: Time.current)
105
+ end
106
+
107
+ def mark_complete!(result)
108
+ status = result&.status
109
+ new_status =
110
+ if @retry_scheduled
111
+ "queued"
112
+ else
113
+ status == :failed ? "failed" : "idle"
114
+ end
115
+ update_source_state(fetch_status: new_status)
116
+ end
117
+
118
+ def mark_failed!(_error)
119
+ @retry_scheduled = false
120
+ update_source_state(fetch_status: "failed")
121
+ end
122
+
123
+ def update_source_state(attrs)
124
+ self.class.send(:update_source_state!, source, attrs)
125
+ end
126
+
127
+ def schedule_retry_if_needed(result)
128
+ decision = result&.retry_decision
129
+ return unless decision&.retry?
130
+
131
+ wait = decision.wait || 0
132
+ queue = SourceMonitor::FetchFeedJob.set(wait: wait)
133
+ queue.perform_later(source.id, force: false)
134
+ @retry_scheduled = true
135
+ rescue StandardError => error
136
+ Rails.logger.error(
137
+ "[SourceMonitor] Failed to enqueue retry for source #{source.id}: #{error.class}: #{error.message}"
138
+ ) if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+
5
+ module SourceMonitor
6
+ module Fetching
7
+ class RetryPolicy
8
+ Decision = Struct.new(
9
+ :retry?,
10
+ :wait,
11
+ :next_attempt,
12
+ :open_circuit?,
13
+ :circuit_until,
14
+ keyword_init: true
15
+ )
16
+
17
+ DEFAULTS = {
18
+ timeout: { attempts: 2, wait: 2.minutes, circuit_wait: 1.hour },
19
+ connection: { attempts: 3, wait: 5.minutes, circuit_wait: 1.hour },
20
+ http_429: { attempts: 2, wait: 15.minutes, circuit_wait: 90.minutes },
21
+ http_5xx: { attempts: 2, wait: 10.minutes, circuit_wait: 90.minutes },
22
+ http_4xx: { attempts: 1, wait: 45.minutes, circuit_wait: 2.hours },
23
+ parsing: { attempts: 1, wait: 30.minutes, circuit_wait: 2.hours },
24
+ unexpected: { attempts: 1, wait: 30.minutes, circuit_wait: 2.hours },
25
+ fallback: { attempts: 2, wait: 10.minutes, circuit_wait: 90.minutes }
26
+ }.freeze
27
+
28
+ attr_reader :source, :error, :now
29
+
30
+ def initialize(source:, error:, now: Time.current)
31
+ @source = source
32
+ @error = error
33
+ @now = now
34
+ end
35
+
36
+ def decision
37
+ policy = DEFAULTS[policy_key]
38
+ attempts = policy.fetch(:attempts)
39
+ wait_duration = policy.fetch(:wait)
40
+ circuit_duration = policy.fetch(:circuit_wait)
41
+
42
+ current_attempt = source.fetch_retry_attempt.to_i
43
+ next_attempt = current_attempt + 1
44
+
45
+ if next_attempt <= attempts
46
+ Decision.new(
47
+ retry?: true,
48
+ wait: wait_duration,
49
+ next_attempt: next_attempt,
50
+ open_circuit?: false,
51
+ circuit_until: nil
52
+ )
53
+ else
54
+ circuit_until = now + circuit_duration
55
+ Decision.new(
56
+ retry?: false,
57
+ wait: circuit_duration,
58
+ next_attempt: 0,
59
+ open_circuit?: true,
60
+ circuit_until: circuit_until
61
+ )
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def policy_key
68
+ return :timeout if error.is_a?(SourceMonitor::Fetching::TimeoutError)
69
+ return :connection if error.is_a?(SourceMonitor::Fetching::ConnectionError)
70
+
71
+ if error.is_a?(SourceMonitor::Fetching::HTTPError)
72
+ status = error.status.to_i
73
+ return :http_429 if status == 429
74
+ return :http_5xx if status >= 500
75
+ return :http_4xx if status >= 400
76
+ end
77
+
78
+ return :parsing if error.is_a?(SourceMonitor::Fetching::ParsingError)
79
+ return :unexpected if error.is_a?(SourceMonitor::Fetching::UnexpectedResponseError)
80
+
81
+ :fallback
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+
5
+ module SourceMonitor
6
+ module Fetching
7
+ class StalledFetchReconciler
8
+ Result = Struct.new(:recovered_source_ids, :jobs_removed, :executed_at, keyword_init: true)
9
+
10
+ FAILURE_MESSAGE = "Fetch job stalled; resetting state and retrying"
11
+
12
+ def self.call(now: Time.current, stale_after: nil)
13
+ new(now:, stale_after: stale_after || default_stale_after).call
14
+ end
15
+
16
+ def initialize(now:, stale_after:)
17
+ @now = now
18
+ @stale_after = stale_after
19
+ end
20
+
21
+ def call
22
+ recovered_ids = []
23
+ removed_job_ids = []
24
+ jobs_supported = jobs_supported?
25
+
26
+ stale_sources.find_each do |source|
27
+ recovery = recover_source(source, jobs_supported:)
28
+ next if recovery.nil?
29
+
30
+ recovered_ids << recovery[:source_id] if recovery[:source_id]
31
+ removed_job_ids.concat(Array(recovery[:removed_job_ids]))
32
+ end
33
+
34
+ Result.new(
35
+ recovered_source_ids: recovered_ids.uniq,
36
+ jobs_removed: removed_job_ids.uniq,
37
+ executed_at: now
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :now, :stale_after
44
+
45
+ def self.default_stale_after
46
+ if defined?(SourceMonitor::Scheduler::STALE_QUEUE_TIMEOUT)
47
+ SourceMonitor::Scheduler::STALE_QUEUE_TIMEOUT
48
+ else
49
+ 10.minutes
50
+ end
51
+ end
52
+
53
+ def stale_sources
54
+ cutoff = now - stale_after
55
+ SourceMonitor::Source.
56
+ where(fetch_status: "fetching").
57
+ where.not(last_fetch_started_at: nil).
58
+ where(SourceMonitor::Source.arel_table[:last_fetch_started_at].lteq(cutoff))
59
+ end
60
+
61
+ def recover_source(source, jobs_supported:)
62
+ removed_job_ids = []
63
+
64
+ source.with_lock do
65
+ source.reload
66
+ return nil unless stale?(source)
67
+
68
+ removed_job_ids = jobs_supported ? discard_jobs_for(source) : []
69
+ mark_source_failed!(source)
70
+ enqueue_recovery(source)
71
+
72
+ { source_id: source.id, removed_job_ids: removed_job_ids }
73
+ end
74
+ rescue StandardError => error
75
+ log_recovery_failure(source, error)
76
+ nil
77
+ end
78
+
79
+ def stale?(source)
80
+ source.fetch_status == "fetching" &&
81
+ source.last_fetch_started_at.present? &&
82
+ source.last_fetch_started_at <= now - stale_after
83
+ end
84
+
85
+ def discard_jobs_for(source)
86
+ return [] unless jobs_supported?
87
+
88
+ matching_jobs = jobs_for(source)
89
+ removed = []
90
+
91
+ matching_jobs.find_each do |job|
92
+ removed << job.id
93
+ if job.failed_execution.present?
94
+ job.failed_execution.discard
95
+ else
96
+ job.destroy!
97
+ end
98
+ end
99
+
100
+ removed
101
+ end
102
+
103
+ def jobs_for(source)
104
+ return ::SolidQueue::Job.none unless jobs_supported?
105
+
106
+ queue_name = SourceMonitor.queue_name(:fetch)
107
+ ::SolidQueue::Job.
108
+ where(queue_name: queue_name).
109
+ where("arguments::jsonb -> 'arguments' ->> 0 = ?", source.id.to_s)
110
+ end
111
+
112
+ def mark_source_failed!(source)
113
+ failure_attrs = {
114
+ fetch_status: "failed",
115
+ last_error: FAILURE_MESSAGE,
116
+ last_error_at: now,
117
+ failure_count: source.failure_count.to_i + 1,
118
+ next_fetch_at: now,
119
+ updated_at: now
120
+ }
121
+
122
+ source.update!(failure_attrs)
123
+ SourceMonitor::Realtime.broadcast_source(source) if SourceMonitor::Realtime.respond_to?(:broadcast_source)
124
+ end
125
+
126
+ def enqueue_recovery(source)
127
+ SourceMonitor::Fetching::FetchRunner.enqueue(source, force: true)
128
+ end
129
+
130
+ def log_recovery_failure(source, error)
131
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
132
+
133
+ Rails.logger.error(
134
+ "[SourceMonitor::Fetching::StalledFetchReconciler] Failed to recover source #{source&.id}: #{error.class}: #{error.message}"
135
+ )
136
+ end
137
+
138
+ def jobs_supported?
139
+ defined?(::SolidQueue::Job) &&
140
+ ::SolidQueue::Job.table_exists?
141
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
142
+ false
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Health
5
+ class SourceHealthCheck
6
+ Result = Struct.new(:log, :success?, :error, keyword_init: true)
7
+
8
+ def initialize(source:, client: nil, now: Time.current)
9
+ @source = source
10
+ @client = client
11
+ @now = now
12
+ end
13
+
14
+ def call
15
+ return Result.new(log: nil, success?: false, error: nil) unless source
16
+
17
+ started_at = now
18
+ response = nil
19
+ error = nil
20
+
21
+ begin
22
+ response = connection.get(source.feed_url)
23
+ rescue StandardError => exception
24
+ error = exception
25
+ end
26
+
27
+ completed_at = Time.current
28
+ log = create_log(response:, error:, started_at:, completed_at:)
29
+
30
+ Result.new(log:, success?: log&.success?, error: error)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :source, :client, :now
36
+
37
+ def connection
38
+ @connection ||= (client || SourceMonitor::HTTP.client(headers: request_headers, retry_requests: false))
39
+ end
40
+
41
+ def request_headers
42
+ headers = (source.custom_headers || {}).each_with_object({}) do |(key, value), memo|
43
+ memo[key.to_s] = value
44
+ end
45
+
46
+ headers["If-None-Match"] = source.etag if source.etag.present?
47
+ headers["If-Modified-Since"] = source.last_modified.httpdate if source.last_modified.present?
48
+ headers
49
+ end
50
+
51
+ def create_log(response:, error:, started_at:, completed_at:)
52
+ attrs = {
53
+ source: source,
54
+ started_at: started_at,
55
+ completed_at: completed_at,
56
+ duration_ms: duration_ms(started_at, completed_at),
57
+ http_status: response_status(response, error),
58
+ http_response_headers: response_headers(response)
59
+ }
60
+
61
+ if error
62
+ attrs[:success] = false
63
+ attrs[:error_class] = error.class.name
64
+ attrs[:error_message] = error.message
65
+ else
66
+ attrs[:success] = successful_status?(response&.status)
67
+ end
68
+
69
+ SourceMonitor::HealthCheckLog.create!(attrs)
70
+ end
71
+
72
+ def duration_ms(started_at, completed_at)
73
+ ((completed_at - started_at) * 1000.0).round
74
+ end
75
+
76
+ def response_status(response, error)
77
+ return response.status if response&.respond_to?(:status)
78
+
79
+ if error.respond_to?(:response)
80
+ response_data = error.response
81
+ return response_data[:status] if response_data.is_a?(Hash)
82
+ end
83
+
84
+ nil
85
+ end
86
+
87
+ def response_headers(response)
88
+ return {} unless response&.respond_to?(:headers)
89
+
90
+ response.headers.each_with_object({}) do |(key, value), memo|
91
+ memo[key.to_s] = value
92
+ end
93
+ end
94
+
95
+ def successful_status?(status)
96
+ status.present? && status.to_i.between?(200, 399)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+
5
+ module SourceMonitor
6
+ module Health
7
+ class SourceHealthMonitor
8
+ attr_reader :source, :config, :now
9
+
10
+ def initialize(source:, config: SourceMonitor.config.health, now: Time.current)
11
+ @source = source
12
+ @config = config
13
+ @now = now
14
+ end
15
+
16
+ def call
17
+ reload_source
18
+
19
+ logs = recent_logs.to_a
20
+ return if logs.empty?
21
+
22
+ rate = calculate_success_rate(logs)
23
+ attrs = { rolling_success_rate: rate }
24
+ sample_size = logs.size
25
+ thresholds_active = thresholds_applicable?(sample_size)
26
+
27
+ auto_paused_until = current_auto_paused_until
28
+ auto_paused_at = current_auto_paused_at
29
+
30
+ if thresholds_active && should_resume?(auto_paused_until, rate)
31
+ auto_paused_until = nil
32
+ auto_paused_at = nil
33
+ attrs[:auto_paused_until] = nil
34
+ attrs[:auto_paused_at] = nil
35
+ attrs[:backoff_until] = nil if source.backoff_until.present?
36
+ end
37
+
38
+ if thresholds_active && should_auto_pause?(rate)
39
+ new_until = compute_auto_pause_until(auto_paused_until)
40
+ auto_paused_until = new_until
41
+ auto_paused_at ||= now
42
+
43
+ attrs[:auto_paused_until] = new_until
44
+ attrs[:auto_paused_at] = auto_paused_at
45
+ apply_backoff(attrs, new_until)
46
+ end
47
+
48
+ enforce_fixed_interval(attrs, auto_paused_until)
49
+
50
+ status = determine_status(rate, auto_paused_until, logs)
51
+ apply_status(attrs, status)
52
+
53
+ source.update!(attrs)
54
+ rescue ActiveRecord::RecordNotFound
55
+ # Source was deleted between fetch and health update.
56
+ nil
57
+ end
58
+
59
+ private
60
+
61
+ def reload_source
62
+ source.reload
63
+ end
64
+
65
+ def recent_logs
66
+ limit = [ config.window_size.to_i, 1 ].max
67
+ source.fetch_logs.order(started_at: :desc).limit(limit)
68
+ end
69
+
70
+ def calculate_success_rate(logs)
71
+ successes = logs.count { |log| log.success? }
72
+ total = logs.size
73
+ return 0.0 if total.zero?
74
+
75
+ (successes.to_f / total).round(4)
76
+ end
77
+
78
+ def current_auto_paused_until
79
+ source.auto_paused_until
80
+ end
81
+
82
+ def current_auto_paused_at
83
+ source.auto_paused_at
84
+ end
85
+
86
+ def should_resume?(auto_paused_until, rate)
87
+ return false if auto_paused_until.nil?
88
+
89
+ rate >= auto_resume_threshold
90
+ end
91
+
92
+ def should_auto_pause?(rate)
93
+ threshold = auto_pause_threshold
94
+ return false if threshold.nil?
95
+
96
+ rate < threshold
97
+ end
98
+
99
+ def compute_auto_pause_until(existing_until)
100
+ cooldown = [ config.auto_pause_cooldown_minutes.to_i, 1 ].max
101
+ proposed = now + cooldown.minutes
102
+
103
+ return proposed if existing_until.nil?
104
+ existing_until > proposed ? existing_until : proposed
105
+ end
106
+
107
+ def apply_backoff(attrs, pause_until)
108
+ if source.next_fetch_at.nil? || source.next_fetch_at < pause_until
109
+ attrs[:next_fetch_at] = pause_until
110
+ end
111
+
112
+ if source.backoff_until.nil? || source.backoff_until < pause_until
113
+ attrs[:backoff_until] = pause_until
114
+ end
115
+ end
116
+
117
+ def determine_status(rate, auto_paused_until, logs)
118
+ if auto_paused_active?(auto_paused_until)
119
+ "auto_paused"
120
+ elsif consecutive_failures(logs) >= 3
121
+ "declining"
122
+ elsif improving_streak?(logs)
123
+ "improving"
124
+ elsif rate >= healthy_threshold
125
+ "healthy"
126
+ elsif rate >= warning_threshold
127
+ "warning"
128
+ else
129
+ "critical"
130
+ end
131
+ end
132
+
133
+ def enforce_fixed_interval(attrs, auto_paused_until)
134
+ return if source.adaptive_fetching_enabled?
135
+ return if auto_paused_active?(auto_paused_until)
136
+
137
+ backoff_value = attrs.key?(:backoff_until) ? attrs[:backoff_until] : source.backoff_until
138
+ return if backoff_value.blank?
139
+
140
+ fixed_minutes = [ source.fetch_interval_minutes.to_i, 1 ].max
141
+ attrs[:next_fetch_at] = now + fixed_minutes.minutes
142
+ attrs[:backoff_until] = nil
143
+ end
144
+
145
+ def apply_status(attrs, status)
146
+ previous = source.health_status.presence || "healthy"
147
+ return if previous == status
148
+
149
+ attrs[:health_status] = status
150
+ attrs[:health_status_changed_at] = now
151
+ end
152
+
153
+ def auto_pause_threshold
154
+ value = source.health_auto_pause_threshold
155
+ value = config.auto_pause_threshold if value.nil?
156
+ value&.to_f
157
+ end
158
+
159
+ def auto_resume_threshold
160
+ [ config.auto_resume_threshold.to_f, auto_pause_threshold.to_f ].max
161
+ end
162
+
163
+ def healthy_threshold
164
+ [ config.healthy_threshold.to_f, warning_threshold ].max
165
+ end
166
+
167
+ def warning_threshold
168
+ config.warning_threshold.to_f
169
+ end
170
+
171
+ def auto_paused_active?(value)
172
+ value.present? && value.future?
173
+ end
174
+
175
+ def thresholds_applicable?(sample_size)
176
+ sample_size >= minimum_sample_size
177
+ end
178
+
179
+ def minimum_sample_size
180
+ [ config.window_size.to_i, 1 ].max
181
+ end
182
+
183
+ def consecutive_failures(logs)
184
+ logs.take_while { |log| !log_success?(log) }.size
185
+ end
186
+
187
+ def improving_streak?(logs)
188
+ success_streak = 0
189
+ failure_seen = false
190
+
191
+ logs.each do |log|
192
+ if log_success?(log)
193
+ success_streak += 1
194
+ else
195
+ failure_seen = true
196
+ break
197
+ end
198
+ end
199
+
200
+ success_streak >= 2 && failure_seen
201
+ end
202
+
203
+ def log_success?(log)
204
+ return log.success? if log.respond_to?(:success?)
205
+
206
+ !!log.success
207
+ end
208
+ end
209
+ end
210
+ end