async-background 1.0.2 → 1.1.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.
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Background
5
+ module Runtime
6
+ class TaskGroup
7
+ attr_accessor :on_release
8
+
9
+ def initialize(on_error: UNSET, on_release: nil)
10
+ @members = {}
11
+ @drained = Notification.new
12
+ @on_error = on_error
13
+ @on_release = on_release
14
+ end
15
+
16
+ def size = @members.size
17
+ def empty? = @members.empty?
18
+ def tasks = @members.keys
19
+
20
+ def spawn(name: nil, &block)
21
+ raise ArgumentError, 'block required' unless block
22
+
23
+ task = Task.new(name: name, group: self, on_error: @on_error)
24
+ @members[task] = true
25
+
26
+ begin
27
+ task.start(&block)
28
+ rescue Exception
29
+ release(task)
30
+ raise
31
+ end
32
+
33
+ task
34
+ end
35
+
36
+ def wait(timeout = nil)
37
+ deadline = Runtime.deadline_for(timeout)
38
+
39
+ until @members.empty?
40
+ raise TimeoutError, 'tasks did not finish in time' unless @drained.wait_until(deadline)
41
+ end
42
+
43
+ true
44
+ end
45
+
46
+ def stop_all(grace = nil)
47
+ tasks.each do |task|
48
+ task.stop
49
+ rescue StandardError
50
+ nil
51
+ end
52
+
53
+ return @members.empty? if grace.nil?
54
+
55
+ begin
56
+ wait(grace)
57
+ rescue TimeoutError
58
+ false
59
+ end
60
+ end
61
+
62
+ def release(task)
63
+ return unless @members.delete(task)
64
+
65
+ @drained.signal_all if @members.empty?
66
+ notify_release(task)
67
+ end
68
+
69
+ private
70
+
71
+ def notify_release(task)
72
+ handler = @on_release or return
73
+
74
+ handler.call(task)
75
+ rescue StandardError => error
76
+ Runtime.report_error(task, error, @on_error)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module Async
6
+ module Background
7
+ class Error < StandardError; end
8
+
9
+ module Runtime
10
+ class Error < Background::Error; end
11
+ class SchedulerRequired < Error; end
12
+ class TimeoutError < Error; end
13
+ class Cancel < Exception; end
14
+ class Deadline < Exception; end
15
+
16
+ UNSET = Object.new
17
+
18
+ CURRENT_TASK_KEY = :async_background_current_task
19
+ WAITER_KEY = :async_background_waiter
20
+
21
+ DEADLINE_MESSAGE = 'execution expired'
22
+
23
+ NO_SCHEDULER_MESSAGE = <<~MESSAGE
24
+ Async::Background requires an active Fiber scheduler.
25
+
26
+ Install one in the host process before calling this, for example:
27
+
28
+ require "async/background/scheduler"
29
+ Async::Background::Scheduler.run { runner.run }
30
+
31
+ or install one yourself:
32
+
33
+ Fiber.set_scheduler(Itsi::Scheduler.new) # itsi-scheduler
34
+ Async { runner.run } # async / falcon
35
+ MESSAGE
36
+
37
+ MISSING_TIMEOUT_HOOK_WARNING = <<~MESSAGE
38
+ Async::Background: %s does not implement #timeout_after.
39
+
40
+ Falling back to stdlib Timeout, which uses Thread#raise and can deliver
41
+ the timeout to an unrelated fiber. Job timeouts are therefore not safe
42
+ on this scheduler. Use a scheduler that implements #timeout_after
43
+ (async, itsi-scheduler) or run jobs with `timeout: nil`.
44
+ MESSAGE
45
+
46
+ @error_handler = nil
47
+ @warned_schedulers = {}
48
+
49
+ module_function
50
+
51
+ def spawn(name: nil, on_error: UNSET, &block)
52
+ Task.spawn(name: name, on_error: on_error, &block)
53
+ end
54
+
55
+ def scheduler
56
+ Fiber.scheduler
57
+ end
58
+
59
+ def scheduler!
60
+ Fiber.scheduler or raise SchedulerRequired, NO_SCHEDULER_MESSAGE
61
+ end
62
+
63
+ def native_timeouts?(target = Fiber.scheduler)
64
+ !target.nil? && target.respond_to?(:timeout_after)
65
+ end
66
+
67
+ def current_task
68
+ fiber_local(CURRENT_TASK_KEY)
69
+ end
70
+
71
+ def current_task=(task)
72
+ set_fiber_local(CURRENT_TASK_KEY, task)
73
+ end
74
+
75
+ def build_waiter(blocker)
76
+ existing = fiber_local(WAITER_KEY)
77
+ if existing&.[](:blocker)
78
+ raise Error, "waiter already parked on #{existing[:blocker].class}; " \
79
+ 'a fiber may only park in one place at a time'
80
+ end
81
+
82
+ waiter = {
83
+ fiber: Fiber.current,
84
+ scheduler: scheduler!,
85
+ ready: false,
86
+ queued: false,
87
+ blocker: blocker
88
+ }
89
+ set_fiber_local(WAITER_KEY, waiter)
90
+ waiter
91
+ end
92
+
93
+ def park(blocker, waiter, deadline = nil)
94
+ scheduler = waiter[:scheduler]
95
+ task = current_task
96
+ task&.enter_block(waiter)
97
+
98
+ until waiter[:ready] || yield
99
+ if deadline
100
+ remaining = deadline - monotonic_now
101
+ return false if remaining <= 0
102
+
103
+ scheduler.block(blocker, remaining)
104
+ else
105
+ scheduler.block(blocker, nil)
106
+ end
107
+
108
+ task&.raise_if_cancelled!
109
+ end
110
+
111
+ true
112
+ ensure
113
+ waiter[:blocker] = nil
114
+ clear_fiber_local(WAITER_KEY, waiter)
115
+ task&.exit_block
116
+ end
117
+
118
+ def with_waiter(blocker, waiters)
119
+ waiter = build_waiter(blocker)
120
+ waiter[:queued] = true
121
+ waiters << waiter
122
+ yield waiter
123
+ ensure
124
+ waiters.delete(waiter) if waiter[:queued]
125
+ end
126
+
127
+ def wake_dequeued(waiter, blocker)
128
+ waiter[:queued] = false
129
+ wake(waiter, blocker)
130
+ end
131
+
132
+ def wake(waiter, blocker)
133
+ return false if waiter[:ready]
134
+
135
+ waiter[:ready] = true
136
+ fiber = waiter[:fiber]
137
+ return false unless fiber.alive?
138
+
139
+ waiter[:scheduler].unblock(blocker, fiber)
140
+ true
141
+ end
142
+
143
+ def monotonic_now
144
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
+ end
146
+
147
+ def deadline_for(timeout)
148
+ return nil if timeout.nil?
149
+
150
+ seconds = Float(timeout)
151
+ raise ArgumentError, 'timeout must be non-negative and finite' unless seconds.finite? && seconds >= 0
152
+
153
+ monotonic_now + seconds
154
+ end
155
+
156
+ def error_handler=(handler)
157
+ @error_handler = handler
158
+ end
159
+
160
+ def error_handler
161
+ @error_handler
162
+ end
163
+
164
+ def with_error_handler(handler)
165
+ previous = @error_handler
166
+ @error_handler = handler
167
+ yield
168
+ ensure
169
+ @error_handler = previous
170
+ end
171
+
172
+ def report_error(task, error, handler = UNSET)
173
+ handler = @error_handler if UNSET.equal?(handler)
174
+ return false unless handler
175
+
176
+ handler.call(task, error)
177
+ true
178
+ rescue StandardError
179
+ false
180
+ end
181
+
182
+ def fiber_local(key)
183
+ Fiber[key]
184
+ rescue ArgumentError, FiberError
185
+ fiber_local_fallback[key]
186
+ end
187
+
188
+ def set_fiber_local(key, value)
189
+ Fiber[key] = value
190
+ rescue ArgumentError, FiberError
191
+ fiber_local_fallback[key] = value
192
+ end
193
+
194
+ def clear_fiber_local(key, expected)
195
+ current = fiber_local(key)
196
+ set_fiber_local(key, nil) if current.equal?(expected)
197
+ end
198
+
199
+ def fiber_local_fallback
200
+ fiber = Fiber.current
201
+ store = fiber.instance_variable_get(:@async_background_locals)
202
+ store || fiber.instance_variable_set(:@async_background_locals, {})
203
+ end
204
+
205
+ def with_timeout(duration, on_timeout: UNSET)
206
+ return yield if duration.nil?
207
+
208
+ seconds = Float(duration)
209
+ raise ArgumentError, 'timeout must be positive and finite' unless seconds.finite? && seconds.positive?
210
+
211
+ begin
212
+ arm_deadline(seconds) { yield }
213
+ rescue Deadline => e
214
+ raise TimeoutError, e.message if UNSET.equal?(on_timeout)
215
+
216
+ on_timeout
217
+ end
218
+ end
219
+
220
+ def arm_deadline(seconds, &block)
221
+ target = scheduler!
222
+
223
+ return target.timeout_after(seconds, Deadline, DEADLINE_MESSAGE, &block) if native_timeouts?(target)
224
+
225
+ warn_missing_timeout_hook(target)
226
+ ::Timeout.timeout(seconds, Deadline, DEADLINE_MESSAGE, &block)
227
+ end
228
+
229
+ def warn_missing_timeout_hook(target)
230
+ key = target.class
231
+ return if @warned_schedulers[key]
232
+
233
+ @warned_schedulers[key] = true
234
+ warn(format(MISSING_TIMEOUT_HOOK_WARNING, key))
235
+ end
236
+ end
237
+ end
238
+ end
239
+
240
+ require_relative 'runtime/notification'
241
+ require_relative 'runtime/semaphore'
242
+ require_relative 'runtime/task'
243
+ require_relative 'runtime/task_group'
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'runtime'
4
+
5
+ module Async
6
+ module Background
7
+ module Scheduler
8
+ ENV_KEY = 'ASYNC_BACKGROUND_SCHEDULER'
9
+ THREAD_ENV_KEY = 'ASYNC_BACKGROUND_SCHEDULER_THREAD'
10
+ KNOWN = %i[async itsi].freeze
11
+
12
+ class UnknownScheduler < ArgumentError; end
13
+ class Unavailable < Background::Error; end
14
+
15
+ module_function
16
+
17
+ def installed? = !Fiber.scheduler.nil?
18
+
19
+ def current
20
+ return nil unless installed?
21
+
22
+ Fiber.scheduler.class.name
23
+ end
24
+
25
+ def preload!(kind = nil)
26
+ case resolve(kind)
27
+ when :async then require 'async'
28
+ when :itsi then require 'itsi/scheduler'
29
+ end
30
+ true
31
+ end
32
+
33
+ def run(kind = nil, &block)
34
+ raise ArgumentError, 'block required' unless block
35
+ return block.call if installed?
36
+
37
+ case resolve(kind)
38
+ when :async then run_async(&block)
39
+ when :itsi then run_itsi(&block)
40
+ end
41
+ end
42
+
43
+ def resolve(kind = nil)
44
+ name = (kind || ENV.fetch(ENV_KEY, 'auto')).to_s.downcase
45
+ return name.to_sym if KNOWN.include?(name.to_sym)
46
+ return detect if name == 'auto'
47
+
48
+ raise UnknownScheduler, "unknown scheduler #{name.inspect}, expected one of: #{KNOWN.join(', ')}, auto"
49
+ end
50
+
51
+ def detect
52
+ return :async if defined?(::Async::Scheduler)
53
+ return :itsi if defined?(::Itsi::Scheduler)
54
+
55
+ return :async if available?('async')
56
+ return :itsi if available?('itsi/scheduler')
57
+
58
+ raise Unavailable, 'no fiber scheduler available: add `async` or `itsi-scheduler` to your bundle'
59
+ end
60
+
61
+ def available?(feature)
62
+ !Gem.find_files(feature).empty? || !Gem.find_files("#{feature}.rb").empty?
63
+ rescue StandardError
64
+ try_require(feature)
65
+ end
66
+
67
+ def try_require(feature)
68
+ require feature
69
+ true
70
+ rescue LoadError
71
+ false
72
+ end
73
+
74
+ def run_async(&block)
75
+ require 'async'
76
+
77
+ result = nil
78
+ send(:Async) { result = block.call }
79
+ result
80
+ end
81
+
82
+ def run_itsi(&block)
83
+ require 'itsi/scheduler'
84
+
85
+ return run_on_thread(::Itsi::Scheduler, &block) if threaded?
86
+
87
+ run_on_current_thread(::Itsi::Scheduler.new, &block)
88
+ end
89
+
90
+ def threaded?
91
+ %w[1 true yes].include?(ENV.fetch(THREAD_ENV_KEY, '').to_s.downcase)
92
+ end
93
+
94
+ def run_on_current_thread(scheduler, &block)
95
+ result = nil
96
+ failure = nil
97
+ finished = false
98
+
99
+ previous = Fiber.scheduler
100
+ Fiber.set_scheduler(scheduler)
101
+
102
+ begin
103
+ Fiber.schedule do
104
+ result = block.call
105
+ rescue Exception => e # rubocop:disable Lint/RescueException
106
+ failure = e
107
+ ensure
108
+ finished = true
109
+ end
110
+
111
+ scheduler.run if scheduler.respond_to?(:run)
112
+ ensure
113
+ Fiber.set_scheduler(previous)
114
+ end
115
+
116
+ raise failure if failure
117
+ unless finished
118
+ raise Unavailable,
119
+ "#{scheduler.class} did not run the scheduled fiber to completion on close; " \
120
+ "set #{THREAD_ENV_KEY}=1 to fall back to a dedicated scheduler thread"
121
+ end
122
+
123
+ result
124
+ end
125
+
126
+ def run_on_thread(scheduler_class, &block)
127
+ result = nil
128
+ failure = nil
129
+
130
+ thread = Thread.new do
131
+ Fiber.set_scheduler(scheduler_class.new)
132
+ Fiber.schedule do
133
+ result = block.call
134
+ rescue Exception => e # rubocop:disable Lint/RescueException
135
+ failure = e
136
+ end
137
+ end
138
+ thread.join
139
+ raise failure if failure
140
+
141
+ result
142
+ end
143
+ end
144
+ end
145
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '1.0.2'
5
+ VERSION = '1.1.0'
6
6
  end
7
7
  end
@@ -32,6 +32,14 @@ module Async
32
32
 
33
33
  private
34
34
 
35
+ LIST_ROUTES = {
36
+ executing: [:executing, :executing, nil].freeze,
37
+ claimed: [:claimed, :claimed, nil].freeze,
38
+ done: [:recent_done, :done, :finished_cursor].freeze,
39
+ failed: [:recent_failed, :failed, :finished_cursor].freeze,
40
+ pending: [:pending, :pending, :pending_cursor].freeze
41
+ }.freeze
42
+
35
43
  def handle(env, head:)
36
44
  return Response.unauthorized unless @auth.authorized?(env)
37
45
 
@@ -68,16 +76,13 @@ module Async
68
76
  end
69
77
 
70
78
  def dispatch(route, env)
79
+ return list_response(route, env) if LIST_ROUTES.key?(route)
80
+
71
81
  case route
72
82
  when :index then Response.html(Assets.render_index(@config))
73
83
  when :javascript then Response.javascript(Assets::JS)
74
84
  when :stylesheet then Response.stylesheet(Assets::CSS)
75
85
  when :overview then overview_response
76
- when :executing then in_flight_response(:executing, env)
77
- when :claimed then in_flight_response(:claimed, env)
78
- when :done then terminal_response(:done, env)
79
- when :failed then terminal_response(:failed, env)
80
- when :pending then pending_response(env)
81
86
  when :metrics then metrics_response
82
87
  when :config then config_response
83
88
  when :stream then stream_response
@@ -85,34 +90,25 @@ module Async
85
90
  end
86
91
  end
87
92
 
88
- def overview_response
89
- Response.json(@serializer.overview(@snapshot.overview, metrics_payload))
90
- end
91
-
92
- def in_flight_response(kind, env)
93
+ def list_response(route, env)
94
+ reader, shape, cursor_kind = LIST_ROUTES.fetch(route)
93
95
  request = Request.new(env, @config)
94
- rows = kind == :executing ? @snapshot.executing(limit: request.limit) : @snapshot.claimed(limit: request.limit)
95
- payload = kind == :executing ? @serializer.executing(rows) : @serializer.claimed(rows)
96
- Response.json({items: payload})
97
- end
98
96
 
99
- def terminal_response(kind, env)
100
- request = Request.new(env, @config)
101
- cursor = request.finished_cursor
102
- rows = kind == :done ? @snapshot.recent_done(limit: request.limit, cursor: cursor) :
103
- @snapshot.recent_failed(limit: request.limit, cursor: cursor)
104
- payload = kind == :done ? @serializer.done(rows) : @serializer.failed(rows)
105
- Response.json(payload)
97
+ unless cursor_kind
98
+ rows = @snapshot.public_send(reader, limit: request.limit)
99
+ return Response.json({items: @serializer.public_send(shape, rows)})
100
+ end
101
+
102
+ rows = @snapshot.public_send(reader, limit: request.limit, cursor: request.public_send(cursor_kind))
103
+ Response.json(@serializer.public_send(shape, rows))
106
104
  end
107
105
 
108
- def pending_response(env)
109
- request = Request.new(env, @config)
110
- rows = @snapshot.pending(limit: request.limit, cursor: request.pending_cursor)
111
- Response.json(@serializer.pending(rows))
106
+ def overview_response
107
+ Response.json(@serializer.overview(@snapshot.overview, metrics_payload))
112
108
  end
113
109
 
114
110
  def metrics_response
115
- Response.json(metrics_payload || {available: false, workers: [], totals: MetricsReader::EMPTY_TOTALS})
111
+ Response.json(metrics_payload || MetricsReader::UNAVAILABLE)
116
112
  end
117
113
 
118
114
  def metrics_payload
@@ -53,18 +53,47 @@ module Async
53
53
  @logger = nil
54
54
  end
55
55
 
56
+ RULES = [
57
+ [:queue_path, ->(value, _) { !value.nil? && !value.to_s.empty? }, 'queue_path must be set'],
58
+ [:auth, ->(value, _) { !value.nil? }, 'auth must be configured (gem ships no permissive default)'],
59
+ [:auth, ->(value, _) { value.respond_to?(:call) },
60
+ 'auth must respond to #call(env) and return truthy on success'],
61
+ [:list_limit, ->(value, _) { value.is_a?(Integer) && value.between?(1, MAX_LIST_LIMIT) },
62
+ "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"],
63
+ [:counts_cache_ttl, ->(value, _) { value.is_a?(Numeric) && value >= 0 },
64
+ 'counts_cache_ttl must be a non-negative Numeric'],
65
+ [:poll_interval_ms, ->(value, _) { value.is_a?(Integer) && value >= 200 },
66
+ 'poll_interval_ms must be an Integer >= 200'],
67
+ [:transport, ->(value, _) { TRANSPORTS.include?(value) },
68
+ "transport must be one of #{TRANSPORTS.inspect}"],
69
+ [:stream_poll_seconds, ->(value, _) { value.is_a?(Numeric) && value >= 0.1 },
70
+ 'stream_poll_seconds must be a Numeric >= 0.1'],
71
+ [:stream_heartbeat_seconds, ->(value, _) { value.is_a?(Numeric) && value >= 5 },
72
+ 'stream_heartbeat_seconds must be a Numeric >= 5'],
73
+ [:stream_retry_ms, ->(value, _) { value.is_a?(Integer) && value >= 500 },
74
+ 'stream_retry_ms must be an Integer >= 500'],
75
+ [:redact_args, ->(value, config) { !config.expose_args || value.nil? || value.respond_to?(:call) },
76
+ 'redact_args must respond to #call(args)'],
77
+ [:total_workers, ->(value, config) {
78
+ !config.metrics_enabled? || (value.is_a?(Integer) && value.positive?)
79
+ }, 'metrics_path requires total_workers to be a positive Integer'],
80
+ [:mount_path, ->(value, _) { value.is_a?(String) }, 'mount_path must be a String'],
81
+ [:mount_path, ->(value, _) { value.empty? || value.start_with?('/') },
82
+ 'mount_path must start with "/" or be empty'],
83
+ [:mount_path, ->(value, _) { value.empty? || !value.end_with?('/') },
84
+ 'mount_path must not end with "/"'],
85
+ [:mount_path, ->(value, _) { value.empty? || !value.match?(/[[:cntrl:]]/) },
86
+ 'mount_path must not contain control characters'],
87
+ [:mount_path, ->(value, _) { value.empty? || !value.match?(/\s/) },
88
+ 'mount_path must not contain whitespace'],
89
+ [:logger, ->(value, _) { value.nil? || (value.respond_to?(:warn) && value.respond_to?(:error)) },
90
+ 'logger must respond to #warn and #error']
91
+ ].freeze
92
+
56
93
  def validate!
57
- validate_queue_path!
58
- validate_auth!
59
- validate_list_limit!
60
- validate_cache_ttl!
61
- validate_poll_interval!
62
- validate_transport!
63
- validate_stream!
64
- validate_redactor!
65
- validate_metrics!
66
- validate_mount_path!
67
- validate_logger!
94
+ RULES.each do |attribute, valid, message|
95
+ raise ConfigurationError, message unless valid.call(public_send(attribute), self)
96
+ end
68
97
  self
69
98
  end
70
99
 
@@ -84,88 +113,6 @@ module Async
84
113
  def metrics_enabled?
85
114
  !metrics_path.nil?
86
115
  end
87
-
88
- private
89
-
90
- def validate_queue_path!
91
- raise ConfigurationError, 'queue_path must be set' if queue_path.nil? || queue_path.to_s.empty?
92
- end
93
-
94
- def validate_auth!
95
- raise ConfigurationError, 'auth must be configured (gem ships no permissive default)' if auth.nil?
96
-
97
- return if auth.respond_to?(:call)
98
-
99
- raise ConfigurationError, 'auth must respond to #call(env) and return truthy on success'
100
- end
101
-
102
- def validate_list_limit!
103
- return if list_limit.is_a?(Integer) && list_limit.between?(1, MAX_LIST_LIMIT)
104
-
105
- raise ConfigurationError, "list_limit must be an Integer between 1 and #{MAX_LIST_LIMIT}"
106
- end
107
-
108
- def validate_cache_ttl!
109
- return if counts_cache_ttl.is_a?(Numeric) && counts_cache_ttl >= 0
110
-
111
- raise ConfigurationError, 'counts_cache_ttl must be a non-negative Numeric'
112
- end
113
-
114
- def validate_poll_interval!
115
- return if poll_interval_ms.is_a?(Integer) && poll_interval_ms >= 200
116
-
117
- raise ConfigurationError, 'poll_interval_ms must be an Integer >= 200'
118
- end
119
-
120
- def validate_transport!
121
- return if TRANSPORTS.include?(transport)
122
-
123
- raise ConfigurationError, "transport must be one of #{TRANSPORTS.inspect}"
124
- end
125
-
126
- def validate_stream!
127
- unless stream_poll_seconds.is_a?(Numeric) && stream_poll_seconds >= 0.1
128
- raise ConfigurationError, 'stream_poll_seconds must be a Numeric >= 0.1'
129
- end
130
-
131
- unless stream_heartbeat_seconds.is_a?(Numeric) && stream_heartbeat_seconds >= 5
132
- raise ConfigurationError, 'stream_heartbeat_seconds must be a Numeric >= 5'
133
- end
134
-
135
- return if stream_retry_ms.is_a?(Integer) && stream_retry_ms >= 500
136
-
137
- raise ConfigurationError, 'stream_retry_ms must be an Integer >= 500'
138
- end
139
-
140
- def validate_redactor!
141
- return unless expose_args && redact_args && !redact_args.respond_to?(:call)
142
-
143
- raise ConfigurationError, 'redact_args must respond to #call(args)'
144
- end
145
-
146
- def validate_metrics!
147
- return unless metrics_enabled?
148
- return if total_workers.is_a?(Integer) && total_workers.positive?
149
-
150
- raise ConfigurationError, 'metrics_path requires total_workers to be a positive Integer'
151
- end
152
-
153
- def validate_mount_path!
154
- raise ConfigurationError, 'mount_path must be a String' unless mount_path.is_a?(String)
155
- return if mount_path.empty?
156
-
157
- raise ConfigurationError, 'mount_path must start with "/" or be empty' unless mount_path.start_with?('/')
158
- raise ConfigurationError, 'mount_path must not end with "/"' if mount_path.end_with?('/')
159
- raise ConfigurationError, 'mount_path must not contain control characters' if mount_path.match?(/[[:cntrl:]]/)
160
- raise ConfigurationError, 'mount_path must not contain whitespace' if mount_path.match?(/\s/)
161
- end
162
-
163
- def validate_logger!
164
- return if logger.nil?
165
- return if logger.respond_to?(:warn) && logger.respond_to?(:error)
166
-
167
- raise ConfigurationError, 'logger must respond to #warn and #error'
168
- end
169
116
  end
170
117
  end
171
118
  end