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.
- checksums.yaml +7 -0
- data/.gitignore +16 -0
- data/.rubocop.yml +12 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +132 -0
- data/CHANGELOG.md +66 -0
- data/CONTRIBUTING.md +31 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +411 -0
- data/MIT-LICENSE +20 -0
- data/README.md +108 -0
- data/Rakefile +8 -0
- data/app/assets/builds/.keep +0 -0
- data/app/assets/config/source_monitor_manifest.js +4 -0
- data/app/assets/images/source_monitor/.keep +0 -0
- data/app/assets/javascripts/source_monitor/application.js +20 -0
- data/app/assets/javascripts/source_monitor/controllers/async_submit_controller.js +36 -0
- data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +109 -0
- data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
- data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +53 -0
- data/app/assets/javascripts/source_monitor/turbo_actions.js +13 -0
- data/app/assets/stylesheets/source_monitor/application.tailwind.css +13 -0
- data/app/assets/svgs/source_monitor/.keep +0 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +81 -0
- data/app/controllers/source_monitor/application_controller.rb +62 -0
- data/app/controllers/source_monitor/dashboard_controller.rb +27 -0
- data/app/controllers/source_monitor/fetch_logs_controller.rb +9 -0
- data/app/controllers/source_monitor/health_controller.rb +10 -0
- data/app/controllers/source_monitor/items_controller.rb +116 -0
- data/app/controllers/source_monitor/logs_controller.rb +15 -0
- data/app/controllers/source_monitor/scrape_logs_controller.rb +9 -0
- data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +35 -0
- data/app/controllers/source_monitor/source_fetches_controller.rb +22 -0
- data/app/controllers/source_monitor/source_health_checks_controller.rb +34 -0
- data/app/controllers/source_monitor/source_health_resets_controller.rb +27 -0
- data/app/controllers/source_monitor/source_retries_controller.rb +22 -0
- data/app/controllers/source_monitor/source_turbo_responses.rb +115 -0
- data/app/controllers/source_monitor/sources_controller.rb +179 -0
- data/app/helpers/source_monitor/application_helper.rb +327 -0
- data/app/jobs/source_monitor/application_job.rb +13 -0
- data/app/jobs/source_monitor/fetch_feed_job.rb +117 -0
- data/app/jobs/source_monitor/item_cleanup_job.rb +48 -0
- data/app/jobs/source_monitor/log_cleanup_job.rb +47 -0
- data/app/jobs/source_monitor/schedule_fetches_job.rb +29 -0
- data/app/jobs/source_monitor/scrape_item_job.rb +47 -0
- data/app/jobs/source_monitor/source_health_check_job.rb +77 -0
- data/app/mailers/source_monitor/application_mailer.rb +17 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/concerns/source_monitor/loggable.rb +18 -0
- data/app/models/source_monitor/application_record.rb +5 -0
- data/app/models/source_monitor/fetch_log.rb +31 -0
- data/app/models/source_monitor/health_check_log.rb +28 -0
- data/app/models/source_monitor/item.rb +102 -0
- data/app/models/source_monitor/item_content.rb +11 -0
- data/app/models/source_monitor/log_entry.rb +56 -0
- data/app/models/source_monitor/scrape_log.rb +31 -0
- data/app/models/source_monitor/source.rb +115 -0
- data/app/views/layouts/source_monitor/application.html.erb +54 -0
- data/app/views/source_monitor/dashboard/_fetch_schedule.html.erb +90 -0
- data/app/views/source_monitor/dashboard/_job_metrics.html.erb +82 -0
- data/app/views/source_monitor/dashboard/_recent_activity.html.erb +39 -0
- data/app/views/source_monitor/dashboard/_stat_card.html.erb +6 -0
- data/app/views/source_monitor/dashboard/_stats.html.erb +9 -0
- data/app/views/source_monitor/dashboard/index.html.erb +48 -0
- data/app/views/source_monitor/fetch_logs/show.html.erb +90 -0
- data/app/views/source_monitor/items/_details.html.erb +234 -0
- data/app/views/source_monitor/items/_details_wrapper.html.erb +3 -0
- data/app/views/source_monitor/items/index.html.erb +147 -0
- data/app/views/source_monitor/items/show.html.erb +3 -0
- data/app/views/source_monitor/logs/index.html.erb +208 -0
- data/app/views/source_monitor/scrape_logs/show.html.erb +73 -0
- data/app/views/source_monitor/shared/_toast.html.erb +34 -0
- data/app/views/source_monitor/sources/_bulk_scrape_form.html.erb +64 -0
- data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +53 -0
- data/app/views/source_monitor/sources/_details.html.erb +302 -0
- data/app/views/source_monitor/sources/_details_wrapper.html.erb +3 -0
- data/app/views/source_monitor/sources/_empty_state_row.html.erb +5 -0
- data/app/views/source_monitor/sources/_fetch_interval_heatmap.html.erb +46 -0
- data/app/views/source_monitor/sources/_form.html.erb +143 -0
- data/app/views/source_monitor/sources/_health_status_badge.html.erb +46 -0
- data/app/views/source_monitor/sources/_row.html.erb +102 -0
- data/app/views/source_monitor/sources/edit.html.erb +28 -0
- data/app/views/source_monitor/sources/index.html.erb +153 -0
- data/app/views/source_monitor/sources/new.html.erb +22 -0
- data/app/views/source_monitor/sources/show.html.erb +3 -0
- data/config/coverage_baseline.json +2010 -0
- data/config/initializers/feedjira.rb +19 -0
- data/config/routes.rb +18 -0
- data/config/tailwind.config.js +17 -0
- data/db/migrate/20241008120000_create_source_monitor_sources.rb +40 -0
- data/db/migrate/20241008121000_create_source_monitor_items.rb +44 -0
- data/db/migrate/20241008122000_create_source_monitor_fetch_logs.rb +32 -0
- data/db/migrate/20241008123000_create_source_monitor_scrape_logs.rb +25 -0
- data/db/migrate/20251008183000_change_fetch_interval_to_minutes.rb +23 -0
- data/db/migrate/20251009090000_create_source_monitor_item_contents.rb +38 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +5 -0
- data/db/migrate/20251010090000_add_adaptive_fetching_toggle_to_sources.rb +7 -0
- data/db/migrate/20251010123000_add_deleted_at_to_source_monitor_items.rb +8 -0
- data/db/migrate/20251010153000_add_type_to_source_monitor_sources.rb +8 -0
- data/db/migrate/20251010154500_add_fetch_status_to_source_monitor_sources.rb +9 -0
- data/db/migrate/20251010160000_create_solid_cable_messages.rb +16 -0
- data/db/migrate/20251011090000_add_fetch_retry_state_to_sources.rb +14 -0
- data/db/migrate/20251012090000_add_health_fields_to_sources.rb +17 -0
- data/db/migrate/20251012100000_optimize_source_monitor_database_performance.rb +13 -0
- data/db/migrate/20251014064947_add_not_null_constraints_to_items.rb +30 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +29 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +18 -0
- data/db/migrate/20251015100000_create_source_monitor_log_entries.rb +89 -0
- data/db/migrate/20251022100000_create_source_monitor_health_check_logs.rb +22 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +29 -0
- data/docs/configuration.md +170 -0
- data/docs/deployment.md +63 -0
- data/docs/gh-cli-workflow.md +44 -0
- data/docs/installation.md +144 -0
- data/docs/troubleshooting.md +76 -0
- data/eslint.config.mjs +27 -0
- data/lib/generators/source_monitor/install/install_generator.rb +59 -0
- data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +155 -0
- data/lib/source_monitor/analytics/source_activity_rates.rb +53 -0
- data/lib/source_monitor/analytics/source_fetch_interval_distribution.rb +57 -0
- data/lib/source_monitor/analytics/sources_index_metrics.rb +92 -0
- data/lib/source_monitor/assets/bundler.rb +49 -0
- data/lib/source_monitor/assets.rb +6 -0
- data/lib/source_monitor/configuration.rb +654 -0
- data/lib/source_monitor/dashboard/queries.rb +356 -0
- data/lib/source_monitor/dashboard/quick_action.rb +7 -0
- data/lib/source_monitor/dashboard/quick_actions_presenter.rb +26 -0
- data/lib/source_monitor/dashboard/recent_activity.rb +30 -0
- data/lib/source_monitor/dashboard/recent_activity_presenter.rb +77 -0
- data/lib/source_monitor/dashboard/turbo_broadcaster.rb +87 -0
- data/lib/source_monitor/dashboard/upcoming_fetch_schedule.rb +126 -0
- data/lib/source_monitor/engine.rb +107 -0
- data/lib/source_monitor/events.rb +110 -0
- data/lib/source_monitor/feedjira_extensions.rb +103 -0
- data/lib/source_monitor/fetching/advisory_lock.rb +54 -0
- data/lib/source_monitor/fetching/completion/event_publisher.rb +22 -0
- data/lib/source_monitor/fetching/completion/follow_up_handler.rb +37 -0
- data/lib/source_monitor/fetching/completion/retention_handler.rb +30 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +627 -0
- data/lib/source_monitor/fetching/fetch_error.rb +88 -0
- data/lib/source_monitor/fetching/fetch_runner.rb +142 -0
- data/lib/source_monitor/fetching/retry_policy.rb +85 -0
- data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +146 -0
- data/lib/source_monitor/health/source_health_check.rb +100 -0
- data/lib/source_monitor/health/source_health_monitor.rb +210 -0
- data/lib/source_monitor/health/source_health_reset.rb +68 -0
- data/lib/source_monitor/health.rb +46 -0
- data/lib/source_monitor/http.rb +85 -0
- data/lib/source_monitor/instrumentation.rb +52 -0
- data/lib/source_monitor/items/item_creator.rb +601 -0
- data/lib/source_monitor/items/retention_pruner.rb +146 -0
- data/lib/source_monitor/items/retention_strategies/destroy.rb +26 -0
- data/lib/source_monitor/items/retention_strategies/soft_delete.rb +50 -0
- data/lib/source_monitor/items/retention_strategies.rb +9 -0
- data/lib/source_monitor/jobs/cleanup_options.rb +85 -0
- data/lib/source_monitor/jobs/fetch_failure_subscriber.rb +129 -0
- data/lib/source_monitor/jobs/solid_queue_metrics.rb +199 -0
- data/lib/source_monitor/jobs/visibility.rb +133 -0
- data/lib/source_monitor/logs/entry_sync.rb +69 -0
- data/lib/source_monitor/logs/filter_set.rb +163 -0
- data/lib/source_monitor/logs/query.rb +81 -0
- data/lib/source_monitor/logs/table_presenter.rb +161 -0
- data/lib/source_monitor/metrics.rb +77 -0
- data/lib/source_monitor/model_extensions.rb +109 -0
- data/lib/source_monitor/models/sanitizable.rb +76 -0
- data/lib/source_monitor/models/url_normalizable.rb +84 -0
- data/lib/source_monitor/pagination/paginator.rb +90 -0
- data/lib/source_monitor/realtime/adapter.rb +97 -0
- data/lib/source_monitor/realtime/broadcaster.rb +237 -0
- data/lib/source_monitor/realtime.rb +17 -0
- data/lib/source_monitor/release/changelog.rb +59 -0
- data/lib/source_monitor/release/runner.rb +73 -0
- data/lib/source_monitor/scheduler.rb +82 -0
- data/lib/source_monitor/scrapers/base.rb +105 -0
- data/lib/source_monitor/scrapers/fetchers/http_fetcher.rb +97 -0
- data/lib/source_monitor/scrapers/parsers/readability_parser.rb +101 -0
- data/lib/source_monitor/scrapers/readability.rb +156 -0
- data/lib/source_monitor/scraping/bulk_result_presenter.rb +85 -0
- data/lib/source_monitor/scraping/bulk_source_scraper.rb +233 -0
- data/lib/source_monitor/scraping/enqueuer.rb +125 -0
- data/lib/source_monitor/scraping/item_scraper/adapter_resolver.rb +44 -0
- data/lib/source_monitor/scraping/item_scraper/persistence.rb +189 -0
- data/lib/source_monitor/scraping/item_scraper.rb +84 -0
- data/lib/source_monitor/scraping/scheduler.rb +43 -0
- data/lib/source_monitor/scraping/state.rb +79 -0
- data/lib/source_monitor/security/authentication.rb +85 -0
- data/lib/source_monitor/security/parameter_sanitizer.rb +42 -0
- data/lib/source_monitor/sources/turbo_stream_presenter.rb +54 -0
- data/lib/source_monitor/turbo_streams/stream_responder.rb +95 -0
- data/lib/source_monitor/version.rb +3 -0
- data/lib/source_monitor.rb +149 -0
- data/lib/tasks/recover_stalled_fetches.rake +16 -0
- data/lib/tasks/source_monitor_assets.rake +28 -0
- data/lib/tasks/source_monitor_tasks.rake +29 -0
- data/lib/tasks/test_smoke.rake +12 -0
- data/package-lock.json +3997 -0
- data/package.json +29 -0
- data/postcss.config.js +6 -0
- data/source_monitor.gemspec +46 -0
- data/stylelint.config.js +12 -0
- 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,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
|