docbrown/app/services/feeds/import.rb
Kirk Haines ed74f2f245
Abstract DatadogStatsClient to ForemStatsClient (#12304)
* This change abstracts the DatadogStatsClient into a ForemStatsClient.
The purpose of this abstraction is to set the foundation for a subsequent PR that will allow one to use New Relic for recording Forem stats, instead of Datadog, if there is a New Relic configuration found.
This specific change creates an abstraction layer that can be built upon, without changing any actual default behavior. All specs still pass.

* Use delegate instead of explicit methods.

* Delegate instead of explicit methods.

* Fix the error.

* Refactor according to the suggestions in the comments.

* Ooops.  Stats work better when all of the code is committed.

* Removing the alias of count to increment since that was done in error.
2021-01-27 11:25:44 -05:00

175 lines
5.9 KiB
Ruby

module Feeds
class Import
def self.call(users: nil, earlier_than: nil)
new(users: users, earlier_than: earlier_than).call
end
def initialize(users: nil, earlier_than: nil)
# using nil here to avoid an unnecessary table count to check presence
@users = users || User.with_feed
@earlier_than = earlier_than
# NOTE: should these be configurable? Currently they are the result of empiric
# tests trying to find a balance between memory occupation and speed
@users_batch_size = 50
@num_fetchers = 8
@num_parsers = 4
end
def call
total_articles_count = 0
users.in_batches(of: users_batch_size) do |batch_of_users|
feeds_per_user_id = fetch_feeds(batch_of_users)
ForemStatsClient.count("feeds::import::fetch_feeds.count", feeds_per_user_id.length)
feedjira_objects = parse_feeds(feeds_per_user_id)
ForemStatsClient.count("feeds::import::parse_feeds.count", feedjira_objects.length)
# NOTE: doing this sequentially to avoid locking problems with the DB
# and unnecessary conflicts
articles = feedjira_objects.flat_map do |user_id, feed|
# TODO: replace `feed` with `feed.url` as `Feeds::AssembleArticleMarkdown`
# only actually uses feed.url
user = batch_of_users.detect { |u| u.id == user_id }
ForemStatsClient.time("feeds::import::create_articles_from_user_feed", tags: ["user_id:#{user_id}"]) do
create_articles_from_user_feed(user, feed)
end
end
total_articles_count += articles.length
articles.each { |article| Slack::Messengers::ArticleFetchedFeed.call(article: article) }
# we use `feed_fetched_at` to mark the last time a particular user's feed has been fetched, parsed and imported
batch_of_users.update_all(feed_fetched_at: Time.current)
end
ForemStatsClient.count("feeds::import::articles.count", total_articles_count)
total_articles_count
end
private
attr_reader :earlier_than, :users_batch_size, :num_fetchers, :num_parsers
def users
return @users unless earlier_than
# Filtering users whose feed hasn't been processed in the last `earlier_than` time span.
# New users + any user whose feed was processed earlier than the given time
@users.where(feed_fetched_at: nil).or(@users.where(feed_fetched_at: ..earlier_than))
end
# TODO: put this in separate service object
def fetch_feeds(batch_of_users)
data = batch_of_users.pluck(:id, :feed_url)
result = Parallel.map(data, in_threads: num_fetchers) do |user_id, url|
cleaned_url = url.to_s.strip
next if cleaned_url.blank?
response = ForemStatsClient.time("feeds::import::fetch_feed", tags: ["user_id:#{user_id}", "url:#{url}"]) do
HTTParty.get(cleaned_url, timeout: 10)
end
[user_id, response.body]
rescue StandardError => e
# TODO: add better exception handling
# For example, we should stop pulling feeds that return 404 and disable them?
report_error(
e,
feeds_import_info: {
user_id: user_id,
url: url,
error: "Feeds::Import::FetchFeedError"
},
)
next
end
result.compact.to_h
end
# TODO: put this in separate service object
def parse_feeds(feeds_per_user_id)
result = Parallel.map(feeds_per_user_id, in_threads: num_parsers) do |user_id, feed_xml|
parsed_feed = ForemStatsClient.time("feeds::import::parse_feed", tags: ["user_id:#{user_id}"]) do
Feedjira.parse(feed_xml)
end
[user_id, parsed_feed]
rescue StandardError => e
# TODO: add better exception handling (eg. rescueing Feedjira::NoParserAvailable separately)
report_error(
e,
feeds_import_info: {
user_id: user_id,
error: "Feeds::Import::ParseFeedError"
},
)
next
end
result.compact.to_h
end
# TODO: currently this is exactly as it was in the RssReader, but we might find
# avenues for optimization, like:
# 1. why are we sending N exists query to the DB, one per each item, can we fetch them all?
# 2. should we queue a batch of workers to create articles, but then, following issues ensue:
# => synchronization on write (table/row locking)
# => what happens if 2 jobs are in the queue for the same article?
# => what happens if they stay in the queue for long and the next iteration of the feeds importer starts?
def create_articles_from_user_feed(user, feed)
articles = []
feed.entries.reverse_each do |item|
next if Feeds::CheckItemMediumReply.call(item) || Feeds::CheckItemPreviouslyImported.call(item, user)
feed_source_url = item.url.strip.split("?source=")[0]
article = Article.create!(
feed_source_url: feed_source_url,
user_id: user.id,
published_from_feed: true,
show_comments: true,
body_markdown: Feeds::AssembleArticleMarkdown.call(item, user, feed, feed_source_url),
organization_id: nil,
)
articles.append(article)
rescue StandardError => e
# TODO: add better exception handling
report_error(
e,
feeds_import_info: {
username: user.username,
feed_url: user.feed_url,
item_count: item_count_error(feed),
error: "Feeds::Import::CreateArticleError:#{item.url}"
},
)
next
end
articles
end
def report_error(error, metadata)
Rails.logger.error(
"feeds::import::error::#{error.class}::#{metadata.merge(error_message: error.message)}",
)
end
def item_count_error(feed)
return "NIL FEED, INVALID URL" unless feed
feed.entries ? feed.entries.length : "no count"
end
end
end