Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ frontend/.astro

# Bundler/vendored dependencies
/vendor/

# Local agent artifacts
/.agents/
4 changes: 2 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ source 'https://rubygems.org'

git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }

# gem 'html2rss', '~> 0.29'
gem 'html2rss', github: 'html2rss/html2rss', branch: 'master'
gem 'html2rss', '~> 0.30'
# gem 'html2rss', github: 'html2rss/html2rss', branch: 'master'
gem 'html2rss-configs', github: 'html2rss/html2rss-configs'

# Use these instead of the two above (uncomment them) when developing locally:
Expand Down
46 changes: 20 additions & 26 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,27 +1,3 @@
GIT
remote: https://github.com/html2rss/html2rss
revision: 4260c842a5632fbef712cf3bee3a4fddcfee3654
branch: master
specs:
html2rss (0.29.1)
addressable (~> 2.7)
brotli
dry-validation
httpx (~> 1.8)
kramdown
mcp (~> 1.2)
mime-types (> 3.0)
nokogiri (>= 1.10, < 2.0)
rack (~> 3.0)
rackup (~> 2.0)
regexp_parser
rss
sanitize
thor
tzinfo
webrick (~> 1.9)
zeitwerk

GIT
remote: https://github.com/html2rss/html2rss-configs
revision: 13cdb5f27bc7c9e943f27ee222eaf13f3dd0c93c
Expand Down Expand Up @@ -165,6 +141,24 @@ GEM
fiber-storage (1.0.1)
hana (1.3.7)
hashdiff (1.2.1)
html2rss (0.30.0)
addressable (~> 2.7)
brotli
dry-validation
httpx (~> 1.8)
kramdown
mcp (~> 1.2)
mime-types (> 3.0)
nokogiri (>= 1.10, < 2.0)
rack (~> 3.0)
rackup (~> 2.0)
regexp_parser
rss
sanitize
thor
tzinfo
webrick (~> 1.9)
zeitwerk
http-2 (1.2.2)
httpx (1.8.3)
http-2 (>= 1.2.0)
Expand Down Expand Up @@ -376,7 +370,7 @@ DEPENDENCIES
base64
climate_control
falcon
html2rss!
html2rss (~> 0.30)
html2rss-configs!
irb
rack-test
Expand Down Expand Up @@ -440,7 +434,7 @@ CHECKSUMS
fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1
hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
html2rss (0.29.1)
html2rss (0.30.0) sha256=a0ec169cf4a41954b2ec327625b15e81d718061a59a0bd27670ca01f238a2852
html2rss-configs (0.2.0)
http-2 (1.2.2) sha256=81b5d45f50fd4cd5f8c5d09651184bec9401e3ef169c3eb3e5b003d5614a92b9
httpx (1.8.3) sha256=cb88f2285c4ef17164d803a640dc393d28722cba7f282ad0e7f9f926cd02dc47
Expand Down
1 change: 1 addition & 0 deletions app/web/boot/setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def sentry_enabled?
#
# @return [void]
def call!
Boot.eager_load! unless EnvironmentValidator.development?
validate_environment!
capture_runtime_env!
configure_sentry!
Expand Down
24 changes: 0 additions & 24 deletions app/web/config/flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,6 @@ module Flags # rubocop:disable Metrics/ModuleLength
default: true,
validator: nil
),
async_feed_refresh_enabled: Definition.new(
name: :async_feed_refresh_enabled,
env_key: 'ASYNC_FEED_REFRESH_ENABLED',
type: :boolean,
default: false,
validator: nil
),
async_feed_refresh_stale_factor: Definition.new(
name: :async_feed_refresh_stale_factor,
env_key: 'ASYNC_FEED_REFRESH_STALE_FACTOR',
type: :integer,
default: 3,
validator: ->(value) { value >= 1 }
),
feeds_cache_max_size: Definition.new(
name: :feeds_cache_max_size,
env_key: 'FEEDS_CACHE_MAX_SIZE',
Expand Down Expand Up @@ -121,16 +107,6 @@ def auto_source_enabled?
fetch(:auto_source_enabled)
end

# @return [Boolean]
def async_feed_refresh_enabled?
fetch(:async_feed_refresh_enabled)
end

# @return [Integer]
def async_feed_refresh_stale_factor
fetch(:async_feed_refresh_stale_factor)
end

# Validates all known flags and managed env key prefixes.
#
# @return [void]
Expand Down
28 changes: 23 additions & 5 deletions app/web/config/runtime_env.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@ module RuntimeEnv
ADMIN_ACCESS_TOKEN_PLACEHOLDER = 'CHANGE_ME_ADMIN_TOKEN'
HEALTH_CHECK_TOKEN_PLACEHOLDER = 'CHANGE_ME_HEALTH_CHECK_TOKEN'
SENSITIVE_KEYS = %w[HTML2RSS_SECRET_KEY HTML2RSS_ACCESS_TOKEN HEALTH_CHECK_TOKEN SENTRY_DSN].freeze
BOOT_METADATA_KEYS = %w[BUILD_TAG GIT_SHA RACK_ENV SENTRY_ENABLE_LOGS].freeze
@mutex = Mutex.new
BOOT_METADATA_KEYS = %w[
BUILD_TAG GIT_SHA RACK_ENV SENTRY_ENABLE_LOGS PORT WEB_CONCURRENCY REQUEST_TIMEOUT_SECONDS
].freeze
# rubocop:disable ThreadSafety/ClassInstanceVariable
@values = nil

class << self
# @return [void]
def capture!
@mutex.synchronize { @values = tracked_env_values.freeze }
@values = tracked_env_values.freeze
scrub_sensitive_env!
nil
end

# @return [void]
def reset!
@mutex.synchronize { @values = nil }
@values = nil
end

# @return [String]
Expand Down Expand Up @@ -78,6 +80,21 @@ def rack_env
fetch('RACK_ENV', ENV.fetch('RACK_ENV', 'development'))
end

# @return [Integer]
def port
fetch('PORT', 4000).to_i
end

# @return [Integer]
def web_concurrency
fetch('WEB_CONCURRENCY', 2).to_i
end

# @return [Float]
def request_timeout_seconds
fetch('REQUEST_TIMEOUT_SECONDS', 55.0).to_f
end

private

# @param key [String]
Expand All @@ -86,7 +103,8 @@ def rack_env
def fetch(key, default = :__missing__)
return ENV.fetch(key) if ENV.key?(key)

current_values = @mutex.synchronize { @values || {} }
current_values = @values || {}
# rubocop:enable ThreadSafety/ClassInstanceVariable
return current_values.fetch(key) if current_values.key?(key)
return default unless default == :__missing__

Expand Down
20 changes: 11 additions & 9 deletions app/web/errors/error_classifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -262,16 +262,14 @@ def initialize(decision)
%w[queue boot].include?(phase)
}, SERVICE_UNAVAILABLE],
[lambda { |c, _|
# Gem wall-clock timeout (Botasaurus 504 / Faraday timeout) — not Timeout::Error.
# Gem wall-clock timeout (Botasaurus 504 / HTTPX timeout) — not Timeout::Error.
# work / nil (transport hop) → site-shaped 504.
defined?(::Html2rss::RequestService::RequestTimedOut) &&
c.any?(::Html2rss::RequestService::RequestTimedOut)
}, GATEWAY_TIMEOUT],
[lambda { |_, err|
defined?(::Rack::Timeout::RequestTimeoutException) && err.is_a?(::Rack::Timeout::RequestTimeoutException)
}, SERVICE_UNAVAILABLE],
[lambda { |c, err|
err.is_a?(Timeout::Error) || err.is_a?(Errno::ETIMEDOUT) ||
(defined?(::Async::TimeoutError) && (err.is_a?(::Async::TimeoutError) || c.any?(::Async::TimeoutError))) ||
(defined?(::HTTPX::TimeoutError) && (err.is_a?(::HTTPX::TimeoutError) || c.any?(::HTTPX::TimeoutError)))
}, GATEWAY_TIMEOUT]
].freeze
Expand Down Expand Up @@ -345,11 +343,15 @@ def decision_for_http_error(error, meta, default_message: nil)
end

def network_error?(error)
error_chain(error).any? do |err|
NETWORK_ERRORS.include?(err.class) ||
(defined?(::HTTPX::Error) &&
(err.is_a?(::HTTPX::ConnectionError) || err.is_a?(::HTTPX::TLSError) || err.is_a?(::HTTPX::TimeoutError)))
end
error_chain(error).any? { |err| network_error_class?(err) }
end

def network_error_class?(err)
return true if NETWORK_ERRORS.include?(err.class)
return true if defined?(::Async::TimeoutError) && err.is_a?(::Async::TimeoutError)
return false unless defined?(::HTTPX::Error)

err.is_a?(::HTTPX::ConnectionError) || err.is_a?(::HTTPX::TLSError) || err.is_a?(::HTTPX::TimeoutError)
end
end
end
Expand Down
93 changes: 66 additions & 27 deletions app/web/feeds/cache.rb
Original file line number Diff line number Diff line change
@@ -1,31 +1,70 @@
# frozen_string_literal: true

require 'concurrent/ivar'
require 'concurrent/map'
require 'async'
require 'async/notification'
require 'digest'
require 'time'

module Html2rss
module Web
module Feeds
##
# Small synchronous cache for canonical feed results.
# Fiber-native cache for canonical feed results.
module Cache
# rubocop:disable ThreadSafety/ClassInstanceVariable
def self.entries
@entries ||= Concurrent::Map.new
@entries ||= {}
end
private_class_method :entries

def self.in_flight
@in_flight ||= Concurrent::Map.new
@in_flight ||= {}
end
private_class_method :in_flight
# rubocop:enable ThreadSafety/ClassInstanceVariable

Entry = Data.define(:result, :expires_at)
DEFAULT_TTL_SECONDS = 3600

##
# Coordinates in-flight fiber coalescing for identical cache keys.
class InFlight
def initialize
@notification = Async::Notification.new
@completed = false
@result = nil
@error = nil
end

# @return [Object]
def wait
return @result if @completed && !@error
raise @error if @completed && @error

@notification.wait
raise @error if @error

@result
end

# @param result [Object]
# @return [void]
def success!(result)
@result = result
@completed = true
@notification.signal(result)
end

# @param error [Exception]
# @return [void]
def failure!(error)
@error = error
@completed = true
@notification.signal(error)
end
end
private_constant :InFlight

class << self
# Converts feed-provided minutes to seconds with a safe fallback.
#
Expand All @@ -44,26 +83,12 @@ def seconds_from_minutes(value, default: DEFAULT_TTL_SECONDS)
# @param cacheable [Boolean, Proc]
# @yieldreturn [Html2rss::Web::Feeds::Contracts::RenderResult]
# @return [Html2rss::Web::Feeds::Contracts::RenderResult]
# rubocop:disable-next Metrics/MethodLength
def fetch(key, ttl_seconds:, cacheable: true)
def fetch(key, ttl_seconds:, cacheable: true, &)
entry = read_entry(key)
return entry.result if fresh?(entry)
return in_flight[key].wait if in_flight.key?(key)

ivar = Concurrent::IVar.new
actual_ivar = in_flight.put_if_absent(key, ivar)
return actual_ivar.value! if actual_ivar

begin
result = yield
write_entry(key, ttl_seconds, result) if cacheable_result?(cacheable, result)
ivar.set(result)
result
rescue StandardError => error
ivar.fail(error)
raise
ensure
in_flight.delete_pair(key, ivar)
end
execute_fetch(key, ttl_seconds, cacheable, &)
end

# @param reason [String]
Expand Down Expand Up @@ -114,20 +139,34 @@ def prune_if_needed

def prune_expired
now = Time.now.utc
entries.each_pair { |k, v| entries.delete(k) if v && now >= v.expires_at }
entries.delete_if { |_k, v| v && now >= v.expires_at }
end

def execute_fetch(key, ttl_seconds, cacheable) # rubocop:disable Metrics/MethodLength
job = in_flight[key] = InFlight.new
begin
result = yield
write_entry(key, ttl_seconds, result) if cacheable_result?(cacheable, result)
job.success!(result)
result
rescue Exception => error # rubocop:disable Lint/RescueException -- Async::Stop inherits from Exception
job.failure!(error)
raise
ensure
in_flight.delete(key)
end
end

def prune_excess(max)
excess = entries.size - (max * 0.9).to_i
return if excess <= 0

entries_by_expiration.first(excess).each { entries.delete(it.first) }
entries_by_expiration.first(excess).each { |pair| entries.delete(pair.first) }
end

def entries_by_expiration
candidates = []
entries.each_pair { |k, v| candidates << [k, v.expires_at] if v&.expires_at }
candidates.sort_by!(&:last)
entries.select { |_k, v| v&.expires_at }
.sort_by { |_k, v| v.expires_at }
end

# @param cacheable [Boolean, Proc]
Expand Down
Loading
Loading