servus 0.7.0 → 1.0.0

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.
data/lib/servus/event.rb CHANGED
@@ -20,7 +20,7 @@ module Servus
20
20
  #
21
21
  # schema payload: { type: 'object', required: ['user_id'] }
22
22
  #
23
- # invoke SendWelcomeEmail::Service, async: true do |payload|
23
+ # enqueue SendWelcomeEmail::Service do |payload|
24
24
  # { user_id: payload[:user_id] }
25
25
  # end
26
26
  # end
@@ -34,13 +34,49 @@ module Servus
34
34
  # class AuditLogCreated < Servus::Event
35
35
  # event_name :audit_log_created
36
36
  #
37
- # invoke AuditLogger::Service, async: true
37
+ # enqueue AuditLogger::Service
38
38
  # end
39
39
  #
40
40
  # @see Servus::Events::Bus
41
41
  # @see Servus::Events::Router
42
42
  # @see Servus::Base
43
43
  class Event
44
+ extend Servus::Schema::Declaration
45
+
46
+ # @!method self.schema(payload: nil)
47
+ # Declares the JSON schema for this event's payload.
48
+ #
49
+ # The payload is validated on every {Servus::Event.emit}. Schemas may
50
+ # reference shared fragments registered with {Servus::Schema.register};
51
+ # refs are resolved on first read.
52
+ #
53
+ # Omitting the keyword leaves any schema declared earlier — or by a
54
+ # superclass — in place. Passing it explicitly as +nil+ raises.
55
+ #
56
+ # @param payload [Hash] JSON schema for the event payload
57
+ # @return [void]
58
+ # @raise [ArgumentError] on an unknown keyword or an explicit nil
59
+ #
60
+ # @example
61
+ # class UserCreated < Servus::Event
62
+ # event_name :user_created
63
+ #
64
+ # schema payload: {
65
+ # type: 'object',
66
+ # required: ['user_id', 'email'],
67
+ # properties: {
68
+ # user_id: { type: 'integer' },
69
+ # email: { type: 'string', format: 'email' }
70
+ # }
71
+ # }
72
+ # end
73
+ #
74
+ # @see Servus::Schema
75
+ #
76
+ # @!method self.payload_schema
77
+ # @return [Hash, nil] the compiled payload schema
78
+ declare_schemas :payload
79
+
44
80
  class << self
45
81
  # Declares or returns the event name.
46
82
  #
@@ -89,37 +125,50 @@ module Servus
89
125
  event_name(name.demodulize.underscore.to_sym)
90
126
  end
91
127
 
92
- # Declares a service invocation in response to the event.
128
+ # Declares a service to enqueue in response to the event.
93
129
  #
94
- # Multiple invocations can be declared for a single event. Each invocation
95
- # requires a block that maps the event payload to the service's arguments.
130
+ # An event can declare as many services as it needs; each is enqueued
131
+ # independently when the event fires. The block maps the event payload to
132
+ # the service's keyword arguments — without one, the full payload is passed
133
+ # through.
96
134
  #
97
- # @param service_class [Class] the service class to invoke (must inherit from Servus::Base)
135
+ # Invocation is always asynchronous. A reaction that ran inline would put
136
+ # its latency and its failures back into the emitting service, which is
137
+ # what events exist to avoid. This requires ActiveJob; see
138
+ # {Servus::Events::Errors::AsyncBackendMissingError}.
139
+ #
140
+ # @param service_class [Class] the service to enqueue (must inherit from Servus::Base)
98
141
  # @param options [Hash] invocation options
99
- # @option options [Boolean] :async invoke the service asynchronously via call_async
100
- # @option options [Symbol] :queue the queue name for async jobs
101
- # @option options [Proc] :if condition that must return true for invocation
102
- # @option options [Proc] :unless condition that must return false for invocation
142
+ # @option options [Symbol] :queue the queue to route the job to
143
+ # @option options [ActiveSupport::Duration] :wait delay before the job runs
144
+ # @option options [Time] :wait_until absolute time to run the job
145
+ # @option options [Integer] :priority job priority (adapter-dependent)
146
+ # @option options [Hash] :job_options additional ActiveJob options
147
+ # @option options [Proc] :if condition that must return true to enqueue
148
+ # @option options [Proc] :unless condition that must return false to enqueue
103
149
  # @yield [payload] block that maps event payload to service arguments
104
150
  # @yieldparam payload [Hash] the event payload
105
151
  # @yieldreturn [Hash] keyword arguments for the service's initialize method
106
152
  # @return [void]
153
+ # @raise [ArgumentError] if the removed +async:+ option is passed
107
154
  #
108
- # @example Basic invocation
109
- # invoke SendEmail::Service do |payload|
155
+ # @example Enqueue a service
156
+ # enqueue SendEmail::Service do |payload|
110
157
  # { user_id: payload[:user_id], email: payload[:email] }
111
158
  # end
112
159
  #
113
- # @example Async invocation with queue
114
- # invoke SendEmail::Service, async: true, queue: :mailers do |payload|
160
+ # @example Route to a queue
161
+ # enqueue SendEmail::Service, queue: :mailers do |payload|
115
162
  # { user_id: payload[:user_id] }
116
163
  # end
117
164
  #
118
- # @example Conditional invocation
119
- # invoke GrantRewards::Service, if: ->(p) { p[:premium] } do |payload|
165
+ # @example Conditional
166
+ # enqueue GrantRewards::Service, if: ->(p) { p[:premium] } do |payload|
120
167
  # { user_id: payload[:user_id] }
121
168
  # end
122
- def invoke(service_class, options = {}, &block)
169
+ def enqueue(service_class, options = {}, &block)
170
+ reject_async_option!(options)
171
+
123
172
  @invocations ||= []
124
173
  @invocations << {
125
174
  service_class: service_class,
@@ -128,6 +177,20 @@ module Servus
128
177
  }
129
178
  end
130
179
 
180
+ # Explains that +invoke+ was renamed, rather than failing as a typo.
181
+ #
182
+ # Event classes load at boot, so a bare NoMethodError here would read like
183
+ # a misspelling instead of a rename. This covers both changes at once,
184
+ # since the overwhelmingly common declaration was +invoke Foo, async: true+.
185
+ #
186
+ # @raise [NoMethodError] always
187
+ # @deprecated Use {#enqueue}.
188
+ def invoke(*_args, **_options, &)
189
+ raise NoMethodError,
190
+ '`invoke` was renamed to `enqueue` in 1.0.0 — event invocation is always ' \
191
+ 'asynchronous. Replace `invoke` with `enqueue`, and drop `async:` if present.'
192
+ end
193
+
131
194
  # Returns all service invocations declared for this event.
132
195
  #
133
196
  # @return [Array<Hash>] array of invocation configurations
@@ -135,34 +198,6 @@ module Servus
135
198
  @invocations || []
136
199
  end
137
200
 
138
- # Defines the JSON schema for validating event payloads.
139
- #
140
- # @param payload [Hash, nil] JSON schema for validating event payloads
141
- # @return [void]
142
- #
143
- # @example
144
- # class UserCreated < Servus::Event
145
- # event_name :user_created
146
- #
147
- # schema payload: {
148
- # type: 'object',
149
- # required: ['user_id', 'email'],
150
- # properties: {
151
- # user_id: { type: 'integer' },
152
- # email: { type: 'string', format: 'email' }
153
- # }
154
- # }
155
- # end
156
- def schema(payload: nil)
157
- @payload_schema = payload.with_indifferent_access if payload
158
- end
159
-
160
- # Returns the payload schema.
161
- #
162
- # @return [Hash, nil] the payload schema or nil if not defined
163
- # @api private
164
- attr_reader :payload_schema
165
-
166
201
  # Emits this event via the Bus.
167
202
  #
168
203
  # Provides a type-safe, discoverable way to emit events from anywhere in
@@ -218,11 +253,31 @@ module Servus
218
253
  # @param payload [Hash] the event payload
219
254
  # @return [Array] results from all invoked services
220
255
  def handle(payload)
221
- invocations_for(payload).map(&:execute)
256
+ invocations_for(payload).map(&:enqueue)
222
257
  end
223
258
 
224
259
  private
225
260
 
261
+ # Rejects the removed +async:+ option at declaration time.
262
+ #
263
+ # Declaration time matters here: an Event class loads at boot, so this
264
+ # fails on deploy rather than on the first emit in production. Rejecting
265
+ # +async: false+ is the point — that declaration asks for synchronous
266
+ # invocation, which no longer exists, and quietly giving it the opposite
267
+ # would be worse than refusing.
268
+ #
269
+ # @param options [Hash]
270
+ # @return [void]
271
+ # @raise [ArgumentError] if +:async+ is present, whatever its value
272
+ # @api private
273
+ def reject_async_option!(options)
274
+ return unless options.key?(:async)
275
+
276
+ raise ArgumentError,
277
+ '`async:` is no longer a valid option — event invocation is always ' \
278
+ 'asynchronous. Remove it from the declaration.'
279
+ end
280
+
226
281
  # @api private
227
282
  def should_invoke?(payload, options)
228
283
  return false if options[:if] && !options[:if].call(payload)
@@ -83,7 +83,7 @@ module Servus
83
83
  ActiveSupport::Notifications.instrument(notification_name(event_name), payload) do
84
84
  resolve_invocations(event_name, payload)
85
85
  .uniq(&:key)
86
- .each(&:execute)
86
+ .each(&:enqueue)
87
87
  end
88
88
  end
89
89
 
@@ -15,6 +15,15 @@ module Servus
15
15
  module Emitter
16
16
  extend ActiveSupport::Concern
17
17
 
18
+ # Triggers accepted by the +emits+ DSL.
19
+ #
20
+ # +:success+ and +:failure+ are selected from the service's result after
21
+ # +call+ returns. +:error!+ is fired by {Servus::Base#error!} immediately
22
+ # before it raises, so it never coincides with +:failure+.
23
+ #
24
+ # Note the bang on +:error!+ — it mirrors the method that triggers it.
25
+ EMISSION_TRIGGERS = %i[success failure error!].freeze
26
+
18
27
  # Emits events for a service result.
19
28
  #
20
29
  # Called automatically after service execution completes. Determines the
@@ -33,7 +42,7 @@ module Servus
33
42
  # Declares an event that this service will emit.
34
43
  #
35
44
  # Events are automatically emitted when the service completes with the specified
36
- # trigger condition (:success, :failure, or :error). Use the `with` option or a
45
+ # trigger condition (:success, :failure, or :error!). Use the `with` option or a
37
46
  # block to provide a custom payload builder. Use `if` or `unless` to gate emission
38
47
  # on a runtime condition.
39
48
  #
@@ -86,34 +95,21 @@ module Servus
86
95
  # end
87
96
  # end
88
97
  #
89
- # @note Best Practice: Services should typically emit ONE event per trigger
90
- # that represents their core concern. Multiple downstream reactions should
91
- # be coordinated by Event classes, not by emitting multiple events
92
- # from the service. This maintains separation of concerns.
93
- #
94
- # @example Recommended pattern (one event, multiple reactions)
95
- # # Service emits one event
98
+ # @example Multiple events on one trigger, each with its own payload
96
99
  # class CreateUser < Servus::Base
97
100
  # emits :user_created, on: :success
98
- # end
99
- #
100
- # # Event coordinates multiple reactions
101
- # class UserCreated < Servus::Event
102
- # event_name :user_created
103
- # invoke SendWelcomeEmail::Service, async: true
104
- # invoke TrackAnalytics::Service, async: true
101
+ # emits :welcome_queued, on: :success, with: :welcome_payload
105
102
  # end
106
103
  #
107
104
  # @see Servus::Events::Bus
108
105
  # @see Servus::Event
109
106
  def emits(event_name, on:, **options, &block)
110
- valid_triggers = %i[success failure error!]
111
-
112
- unless valid_triggers.include?(on)
113
- raise ArgumentError, "Invalid trigger: #{on}. Must be one of: #{valid_triggers.join(', ')}"
107
+ unless EMISSION_TRIGGERS.include?(on)
108
+ raise ArgumentError,
109
+ "Invalid trigger: #{on}. Must be one of: #{EMISSION_TRIGGERS.join(', ')}"
114
110
  end
115
111
 
116
- @event_emissions ||= { success: [], failure: [], error!: [] }
112
+ @event_emissions ||= empty_emissions
117
113
  @event_emissions[on] << build_emission(event_name, options, block)
118
114
  end
119
115
 
@@ -121,20 +117,25 @@ module Servus
121
117
  #
122
118
  # @return [Hash] hash of event emissions grouped by trigger
123
119
  # { success: [...], failure: [...], error!: [...] }
124
- def event_emissions
125
- @event_emissions || { success: [], failure: [], error!: [] }
126
- end
120
+ def event_emissions = @event_emissions || empty_emissions
127
121
 
128
122
  # Returns event emissions for a specific trigger.
129
123
  #
130
124
  # @param trigger [Symbol] the trigger type (:success, :failure, :error!)
131
125
  # @return [Array<Hash>] array of event configurations for this trigger
132
- def emissions_for(trigger)
133
- event_emissions[trigger] || []
134
- end
126
+ def emissions_for(trigger) = event_emissions[trigger] || []
135
127
 
136
128
  private
137
129
 
130
+ # An empty emission set, one entry per supported trigger.
131
+ #
132
+ # Derived from {Emitter::EMISSION_TRIGGERS} rather than written out, so
133
+ # the shape cannot drift from the list of triggers actually accepted.
134
+ #
135
+ # @return [Hash{Symbol => Array}]
136
+ # @api private
137
+ def empty_emissions = EMISSION_TRIGGERS.to_h { |trigger| [trigger, []] }
138
+
138
139
  def build_emission(event_name, options, block)
139
140
  {
140
141
  event_name: event_name,
@@ -201,11 +202,31 @@ module Servus
201
202
  # @api private
202
203
  def validate_event_payload!(event_name, payload)
203
204
  event_class = Servus::Events::Bus.event_for(event_name)
204
- return unless event_class
205
+ return require_event_schema!(event_name) unless event_class
205
206
 
206
207
  Servus::Support::Validator.validate_event_payload!(event_class, payload)
207
208
  end
208
209
 
210
+ # Enforces {Servus::Config#require_event_payload_schema} for an event that
211
+ # has no Event class to carry a schema.
212
+ #
213
+ # An unregistered event name is the one case where a payload cannot be
214
+ # validated at all, so it is exactly where the flag matters most. Skipping
215
+ # it here would mean the setting silently passed over the events furthest
216
+ # from having a contract.
217
+ #
218
+ # @param event_name [Symbol] the event being emitted
219
+ # @return [void]
220
+ # @raise [Servus::Support::Errors::SchemaRequiredError] if enforcement is enabled
221
+ # @api private
222
+ def require_event_schema!(event_name)
223
+ return unless Servus.config.require_event_payload_schema
224
+
225
+ raise Servus::Support::Errors::SchemaRequiredError,
226
+ "#{self.class} emits :#{event_name} but no Event class is registered for it — " \
227
+ 'schema missing! require_event_payload_schema is set to true.'
228
+ end
229
+
209
230
  # Builds the event payload using the configured payload builder or defaults.
210
231
  #
211
232
  # @param emission [Hash] the emission configuration
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Events
5
+ # Errors raised while resolving or enqueueing event invocations.
6
+ #
7
+ # These deliberately do *not* inherit from {Servus::Support::Errors::ServiceError}.
8
+ # Everything in that hierarchy carries an +#http_status+ and an +#api_error+
9
+ # because it describes a business outcome a caller might render. A missing job
10
+ # backend, or a service that cannot be enqueued, is a configuration problem —
11
+ # there is no sensible HTTP status for it.
12
+ #
13
+ # @see Servus::Events::Invocation
14
+ module Errors
15
+ # Base class for every event invocation error.
16
+ class Error < StandardError; end
17
+
18
+ # Raised when an event invocation cannot be enqueued because ActiveJob is
19
+ # not loaded.
20
+ #
21
+ # Event invocation is always asynchronous, so an event that reacts to
22
+ # anything needs a job backend. Servus's core — services, schemas, guards,
23
+ # and the bus itself — works without one; only +enqueue+ declarations
24
+ # require it.
25
+ class AsyncBackendMissingError < Error
26
+ # @param service [Class] the service that could not be enqueued
27
+ # @return [AsyncBackendMissingError]
28
+ def self.for(service)
29
+ new(
30
+ "Cannot enqueue #{service} from an event: ActiveJob is not loaded. " \
31
+ 'Event invocation is always asynchronous and runs through ActiveJob. ' \
32
+ 'Require active_job, or remove the enqueue declaration.'
33
+ )
34
+ end
35
+ end
36
+
37
+ # Raised when a service has no name, so no job class can be generated for it.
38
+ #
39
+ # ActiveJob resolves a job on the worker by its serialized class name, so a
40
+ # service created with +Class.new(Servus::Base)+ has nothing to serialize.
41
+ # This surfaces almost exclusively in tests — assign the class to a constant,
42
+ # or use +stub_const+.
43
+ class AnonymousServiceError < Error
44
+ # @param service [Class] the anonymous service
45
+ # @return [AnonymousServiceError]
46
+ def self.for(service)
47
+ new(
48
+ "Cannot generate a job class for #{service.inspect}: it is anonymous. " \
49
+ 'ActiveJob resolves jobs by class name, so a service must be assigned ' \
50
+ 'to a constant before it can be enqueued.'
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -12,61 +12,54 @@ module Servus
12
12
  # deduplicates by +#key+ (first wins), and calls +#execute+ on each.
13
13
  #
14
14
  # An Invocation separates *identity* (service + params) from
15
- # *execution strategy* (async, queue, priority, etc.). The +#key+
16
- # is derived only from the identity — two invocations that call the
17
- # same service with the same params are considered duplicates
18
- # regardless of their options.
15
+ # *scheduling* (queue, priority, delay). The +#key+ is derived only
16
+ # from the identity — two invocations that call the same service with
17
+ # the same params are considered duplicates regardless of their options.
19
18
  #
20
- # @example Sync invocation
21
- # Invocation.new(
22
- # service: Rewards::Grant::Service,
23
- # params: { user_id: "abc-123" },
24
- # options: {}
25
- # )
19
+ # Invocations are always enqueued, never run inline. A reaction that ran
20
+ # synchronously would put its latency and its failures back into the
21
+ # emitting service, which is what events exist to avoid.
26
22
  #
27
- # @example Async invocation with scheduling options
23
+ # @example
28
24
  # Invocation.new(
29
25
  # service: Notifications::Send::Service,
30
26
  # params: { user_id: "abc-123" },
31
- # options: { async: true, queue: :mailers, priority: 5 }
27
+ # options: { queue: :mailers, priority: 5 }
32
28
  # )
33
29
  #
34
30
  # @see Servus::Events::Router
35
31
  # @see Servus::Events::Bus
36
32
  class Invocation
37
- # @return [Class] the service class to call (must respond to +.call+ or +.call_async+)
33
+ # @return [Class] the service class to enqueue (must respond to +.call_async+)
38
34
  attr_reader :service
39
35
 
40
36
  # @return [Hash] keyword arguments passed to the service
41
37
  attr_reader :params
42
38
 
43
- # @return [Hash] execution options — +async+, +queue+, +wait+,
44
- # +wait_until+, +priority+, +job_options+
39
+ # @return [Hash] scheduling options — +queue+, +wait+, +wait_until+,
40
+ # +priority+, +job_options+
45
41
  attr_reader :options
46
42
 
47
43
  # @param service [Class] the service class
48
44
  # @param params [Hash] keyword arguments for the service
49
- # @param options [Hash] execution options
45
+ # @param options [Hash] scheduling options
50
46
  def initialize(service:, params:, options: {})
51
47
  @service = service
52
48
  @params = params
53
49
  @options = options
54
50
  end
55
51
 
56
- # Executes the invocation.
52
+ # Enqueues the invocation via ActiveJob.
57
53
  #
58
- # Delegates to +service.call+ for synchronous invocations or
59
- # +service.call_async+ for asynchronous ones. Async scheduling
60
- # options (queue, wait, priority, etc.) are merged into the
61
- # call_async kwargs.
54
+ # Scheduling options (queue, wait, priority, and so on) are merged into
55
+ # the +call_async+ keyword arguments.
62
56
  #
63
- # @return [Servus::Support::Response, void]
64
- def execute
65
- if options[:async]
66
- service.call_async(**params, **async_options)
67
- else
68
- service.call(**params)
69
- end
57
+ # @return [void]
58
+ # @raise [Servus::Events::Errors::AsyncBackendMissingError] if ActiveJob is not loaded
59
+ def enqueue
60
+ raise Errors::AsyncBackendMissingError.for(service) unless service.respond_to?(:call_async)
61
+
62
+ service.call_async(**params, **async_options)
70
63
  end
71
64
 
72
65
  # A deterministic deduplication key derived from the service class
@@ -86,6 +79,7 @@ module Servus
86
79
  # Extracts scheduling options for +call_async+.
87
80
  #
88
81
  # @return [Hash]
82
+ # @api private
89
83
  def async_options
90
84
  options.slice(:queue, :wait, :wait_until, :priority, :job_options).compact
91
85
  end
@@ -81,6 +81,11 @@ module Servus
81
81
  # The named job class identifies the service — only args are serialized.
82
82
  job = job_options.any? ? servus_job_class.set(**job_options) : servus_job_class
83
83
  job.perform_later(**args)
84
+ rescue Servus::Support::Errors::ServiceError, Servus::Events::Errors::Error
85
+ # With the :inline and :test adapters perform_later runs the service,
86
+ # so Servus's own errors surface here. Wrapping them as an enqueue
87
+ # failure would blame the wrong layer.
88
+ raise
84
89
  rescue StandardError => e
85
90
  raise Errors::JobEnqueueError, "Failed to enqueue async job for #{self}: #{e.message}"
86
91
  end
@@ -164,6 +169,8 @@ module Servus
164
169
  # @return [Class<Servus::Extensions::Async::Job>] the generated job class
165
170
  # @api private
166
171
  def build_servus_job_class
172
+ raise Servus::Events::Errors::AnonymousServiceError.for(self) if name.nil?
173
+
167
174
  klass = Class.new(Servus::Extensions::Async::Job)
168
175
  klass.servus_service = self
169
176
 
@@ -39,46 +39,6 @@ module Servus
39
39
  render_service_error(@result.error) unless @result.success?
40
40
  end
41
41
 
42
- # Executes a service and returns its data on success, raising the
43
- # failure's error otherwise.
44
- #
45
- # The bang counterpart to {#run_service}. Use it outside a standard
46
- # controller render flow — inside background logic, callbacks, or any
47
- # place where a failure should propagate as an exception rather than be
48
- # rendered as JSON.
49
- #
50
- # Inside a service's `#call` method, use {Servus::Base#call!} instead —
51
- # it preserves the failure Response for the outer service's caller rather
52
- # than raising.
53
- #
54
- # Mirrors {#run_service}: stores the full Response in @result so views
55
- # and downstream helpers can reach for it the same way, then returns the
56
- # data on success or raises on failure. The only behavioural difference
57
- # between the two is raise-vs-render on failure.
58
- #
59
- # Sugar over:
60
- #
61
- # @result = Service.call(**params)
62
- # raise @result.error unless @result.success?
63
- # data = @result.data
64
- #
65
- # @example From a rake task
66
- # data = run_service!(Treasury::Reconcile::Service, date: Date.current)
67
- #
68
- # @param klass [Class<Servus::Base>] service class to execute
69
- # @param params [Hash] keyword arguments to pass to the service
70
- # @return [Servus::Support::DataObject, Object] the service's data on success
71
- # @raise [Servus::Support::Errors::ServiceError] the failure's error otherwise
72
- #
73
- # @see #run_service
74
- # @see Servus::Base#call!
75
- def run_service!(klass, **params)
76
- @result = klass.call(**params)
77
- return @result.data if @result.success?
78
-
79
- raise @result.error
80
- end
81
-
82
42
  # Renders a service error as a JSON response.
83
43
  #
84
44
  # Uses error.http_status for the response status code and
@@ -41,6 +41,9 @@ module Servus
41
41
 
42
42
  Servus::Events::Bus.clear if Rails.env.development?
43
43
 
44
+ # Schemas are cached per class, and reloading replaces those classes.
45
+ Servus::Support::Validator.clear_cache!
46
+
44
47
  # Eager load all event classes
45
48
  events_path = Rails.root.join(Servus.config.events_dir)
46
49
  Dir[File.join(events_path, '**/*_event.rb')].each do |file|
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Schema
5
+ # Memoized +$ref+ resolutions, plus the generation counter that invalidates
6
+ # everything derived from them.
7
+ #
8
+ # Entries are keyed by ref string and hold the resolved *target* of that
9
+ # ref, before any sibling properties are merged over it. That is what makes
10
+ # a single entry safe to share across every site that uses the ref: the
11
+ # target depends only on the ref string and the registry contents, and
12
+ # callers apply their own siblings afterwards with +Hash#merge+, which
13
+ # returns a new hash and never mutates the cached one.
14
+ #
15
+ # The generation counter lets consumers that build on compiled schemas —
16
+ # {Servus::Base}, {Servus::Event} — memoize alongside the generation they
17
+ # compiled under and rebuild when it moves, with no dependency tracking.
18
+ #
19
+ # @see Servus::Schema
20
+ # @see Servus::Schema::Compiler
21
+ # @api private
22
+ class Cache
23
+ # Monotonic counter, advanced by {#invalidate!}.
24
+ #
25
+ # @return [Integer]
26
+ attr_reader :generation
27
+
28
+ def initialize
29
+ @entries = {}
30
+ @generation = 0
31
+ @mutex = Mutex.new
32
+ end
33
+
34
+ # Returns the memoized resolution of +ref+, computing it on a miss.
35
+ #
36
+ # A raising block leaves no entry behind, so a ref that failed part way
37
+ # through resolution is never cached in a half-built state.
38
+ #
39
+ # @param ref [String] the ref string
40
+ # @yieldreturn [Object] the resolved target, computed on a miss
41
+ # @return [Object] the resolved target
42
+ def resolve(ref)
43
+ cached = @entries[ref]
44
+ return cached unless cached.nil?
45
+
46
+ resolved = yield
47
+ @mutex.synchronize { @entries[ref] = resolved }
48
+ resolved
49
+ end
50
+
51
+ # Discards every memoized resolution and advances {#generation}.
52
+ #
53
+ # @return [void]
54
+ def invalidate!
55
+ @mutex.synchronize do
56
+ @entries = {}
57
+ @generation += 1
58
+ end
59
+ end
60
+
61
+ # Number of memoized resolutions. Used by specs to assert that a repeated
62
+ # ref is expanded once rather than once per occurrence.
63
+ #
64
+ # @return [Integer]
65
+ def size
66
+ @entries.size
67
+ end
68
+ end
69
+ end
70
+ end