* 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.
175 lines
5.9 KiB
Ruby
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
|