11# frozen_string_literal: true
22
3- require 'concurrent/ivar '
4- require 'concurrent/map '
3+ require 'async '
4+ require 'async/notification '
55require 'digest'
66require 'time'
77
88module Html2rss
99 module Web
1010 module Feeds
1111 ##
12- # Small synchronous cache for canonical feed results.
12+ # Fiber-native cache for canonical feed results.
1313 module Cache
1414 # rubocop:disable ThreadSafety/ClassInstanceVariable
1515 def self . entries
16- @entries ||= Concurrent :: Map . new
16+ @entries ||= { }
1717 end
1818 private_class_method :entries
1919
2020 def self . in_flight
21- @in_flight ||= Concurrent :: Map . new
21+ @in_flight ||= { }
2222 end
2323 private_class_method :in_flight
2424 # rubocop:enable ThreadSafety/ClassInstanceVariable
2525
2626 Entry = Data . define ( :result , :expires_at )
2727 DEFAULT_TTL_SECONDS = 3600
2828
29+ ##
30+ # Coordinates in-flight fiber coalescing for identical cache keys.
31+ class InFlight
32+ def initialize
33+ @notification = Async ::Notification . new
34+ @completed = false
35+ @result = nil
36+ @error = nil
37+ end
38+
39+ # @return [Object]
40+ def wait
41+ return @result if @completed && !@error
42+ raise @error if @completed && @error
43+
44+ @notification . wait
45+ raise @error if @error
46+
47+ @result
48+ end
49+
50+ # @param result [Object]
51+ # @return [void]
52+ def success! ( result )
53+ @result = result
54+ @completed = true
55+ @notification . signal ( result )
56+ end
57+
58+ # @param error [Exception]
59+ # @return [void]
60+ def failure! ( error )
61+ @error = error
62+ @completed = true
63+ @notification . signal ( error )
64+ end
65+ end
66+ private_constant :InFlight
67+
2968 class << self
3069 # Converts feed-provided minutes to seconds with a safe fallback.
3170 #
@@ -44,26 +83,12 @@ def seconds_from_minutes(value, default: DEFAULT_TTL_SECONDS)
4483 # @param cacheable [Boolean, Proc]
4584 # @yieldreturn [Html2rss::Web::Feeds::Contracts::RenderResult]
4685 # @return [Html2rss::Web::Feeds::Contracts::RenderResult]
47- # rubocop:disable-next Metrics/MethodLength
48- def fetch ( key , ttl_seconds :, cacheable : true )
86+ def fetch ( key , ttl_seconds :, cacheable : true , &)
4987 entry = read_entry ( key )
5088 return entry . result if fresh? ( entry )
89+ return in_flight [ key ] . wait if in_flight . key? ( key )
5190
52- ivar = Concurrent ::IVar . new
53- actual_ivar = in_flight . put_if_absent ( key , ivar )
54- return actual_ivar . value! if actual_ivar
55-
56- begin
57- result = yield
58- write_entry ( key , ttl_seconds , result ) if cacheable_result? ( cacheable , result )
59- ivar . set ( result )
60- result
61- rescue StandardError => error
62- ivar . fail ( error )
63- raise
64- ensure
65- in_flight . delete_pair ( key , ivar )
66- end
91+ execute_fetch ( key , ttl_seconds , cacheable , &)
6792 end
6893
6994 # @param reason [String]
@@ -114,20 +139,34 @@ def prune_if_needed
114139
115140 def prune_expired
116141 now = Time . now . utc
117- entries . each_pair { |k , v | entries . delete ( k ) if v && now >= v . expires_at }
142+ entries . delete_if { |_k , v | v && now >= v . expires_at }
143+ end
144+
145+ def execute_fetch ( key , ttl_seconds , cacheable ) # rubocop:disable Metrics/MethodLength
146+ job = in_flight [ key ] = InFlight . new
147+ begin
148+ result = yield
149+ write_entry ( key , ttl_seconds , result ) if cacheable_result? ( cacheable , result )
150+ job . success! ( result )
151+ result
152+ rescue Exception => error # rubocop:disable Lint/RescueException -- Async::Stop inherits from Exception
153+ job . failure! ( error )
154+ raise
155+ ensure
156+ in_flight . delete ( key )
157+ end
118158 end
119159
120160 def prune_excess ( max )
121161 excess = entries . size - ( max * 0.9 ) . to_i
122162 return if excess <= 0
123163
124- entries_by_expiration . first ( excess ) . each { entries . delete ( it . first ) }
164+ entries_by_expiration . first ( excess ) . each { | pair | entries . delete ( pair . first ) }
125165 end
126166
127167 def entries_by_expiration
128- candidates = [ ]
129- entries . each_pair { |k , v | candidates << [ k , v . expires_at ] if v &.expires_at }
130- candidates . sort_by! ( &:last )
168+ entries . select { |_k , v | v &.expires_at }
169+ . sort_by { |_k , v | v . expires_at }
131170 end
132171
133172 # @param cacheable [Boolean, Proc]
0 commit comments