rage-rb 1.26.1 → 1.28.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.
@@ -4,13 +4,27 @@ class Rage::Deferred::Backends::Nil
4
4
  def initialize(**)
5
5
  end
6
6
 
7
- def add(_, **)
7
+ def add_task(_, **)
8
8
  end
9
9
 
10
- def remove(_)
10
+ def remove_task(_)
11
11
  end
12
12
 
13
13
  def pending_tasks
14
14
  []
15
15
  end
16
+
17
+ def add_dead_task(_, _, _, **)
18
+ end
19
+
20
+ def list_dead_tasks(**)
21
+ []
22
+ end
23
+
24
+ def find_dead_task(_)
25
+ end
26
+
27
+ def remove_dead_tasks(_)
28
+ 0
29
+ end
16
30
  end
@@ -70,7 +70,7 @@ module Rage::Deferred
70
70
  __queue.schedule(task_id, task_wrapper, publish_in:)
71
71
  rescue => e
72
72
  puts "ERROR: Failed to load deferred task #{task_id}: #{e.class} (#{e.message}). Removing task from the queue."
73
- __backend.remove(task_id)
73
+ __backend.remove_task(task_id)
74
74
  end
75
75
  end
76
76
 
@@ -99,6 +99,10 @@ module Rage::Deferred
99
99
 
100
100
  class PushTimeout < StandardError
101
101
  end
102
+
103
+ # Raised when a dead-tasks store operation cannot acquire the lock within the retry budget.
104
+ class DeadTasksLockTimeout < StandardError
105
+ end
102
106
  end
103
107
 
104
108
  require_relative "task"
@@ -21,7 +21,7 @@ class Rage::Deferred::Queue
21
21
  [delay_until_i - current_time_i, delay_until_i] if delay_until_i > current_time_i
22
22
  end
23
23
 
24
- persisted_task_id = @backend.add(context, publish_at:, task_id:)
24
+ persisted_task_id = @backend.add_task(context, publish_at:, task_id:)
25
25
  schedule(persisted_task_id, context, publish_in:)
26
26
  end
27
27
 
@@ -41,14 +41,14 @@ class Rage::Deferred::Queue
41
41
  result = task.new.__perform(context)
42
42
 
43
43
  if result == true
44
- @backend.remove(task_id)
44
+ @backend.remove_task(task_id)
45
45
  else
46
46
  attempts = Rage::Deferred::Context.inc_attempts(context)
47
47
  retry_in = task.__next_retry_in(attempts, result)
48
48
  if retry_in
49
49
  enqueue(context, delay: retry_in, task_id:)
50
50
  else
51
- @backend.remove(task_id)
51
+ abandon_task(task_id, context, result, task_class: task, attempts:)
52
52
  end
53
53
  end
54
54
 
@@ -74,4 +74,9 @@ class Rage::Deferred::Queue
74
74
  end
75
75
  end
76
76
  end
77
+
78
+ def abandon_task(task_id, context, result, task_class:, attempts:)
79
+ @backend.add_dead_task(task_id, context, result, task_class:, attempts:)
80
+ @backend.remove_task(task_id)
81
+ end
77
82
  end
@@ -73,6 +73,7 @@ module Rage::Deferred::Task
73
73
  Rage.logger.with_context(task_log_context) do
74
74
  args = Rage::Deferred::Context.get_args(context)
75
75
  kwargs = Rage::Deferred::Context.get_kwargs(context)
76
+ kwargs ||= {} if RUBY_VERSION.start_with?("3.3.")
76
77
 
77
78
  perform(*args, **kwargs)
78
79
  end
@@ -75,19 +75,23 @@ class Rage::FiberScheduler
75
75
  Fiber.pause if duration.nil? || duration < 1
76
76
  end
77
77
 
78
- # TODO: GC works a little strange with this closure;
79
- #
80
- # def timeout_after(duration, exception_class = Timeout::Error, *exception_arguments, &block)
81
- # fiber, block_status = Fiber.current, :running
82
- # ::Iodine.run_after((duration * 1000).to_i) do
83
- # fiber.raise(exception_class, exception_arguments) if block_status == :running
84
- # end
85
-
86
- # result = block.call
87
- # block_status = :finished
88
-
89
- # result
90
- # end
78
+ if ENV["RAGE_ENABLE_NON_BLOCKING_TIMEOUT"]
79
+ def timeout_after(duration, exception_class = Timeout::Error, *exception_arguments, &block)
80
+ f, fulfilled = Fiber.current, false
81
+
82
+ ::Iodine.run_after((duration * 1000).to_i) do
83
+ if !fulfilled && f.alive?
84
+ fulfilled = true
85
+ f.__wait_generation += 1
86
+ f.raise(exception_class, *exception_arguments)
87
+ end
88
+ end
89
+
90
+ block.call
91
+ ensure
92
+ fulfilled = true
93
+ end
94
+ end
91
95
 
92
96
  # Resolve a hostname to IP addresses, caching results for 60 seconds.
93
97
  def address_resolve(hostname)
data/lib/rage/hooks.rb CHANGED
@@ -10,7 +10,7 @@ module Hooks
10
10
  hooks[hook_family] << callback if callback.is_a?(Proc)
11
11
  end
12
12
 
13
- def run_hooks_for!(hook_family, context = nil)
13
+ def run_hooks_for(hook_family, context = nil)
14
14
  hooks[hook_family].each do |callback|
15
15
  if context
16
16
  context.instance_exec(&callback)
@@ -19,6 +19,11 @@ module Hooks
19
19
  end
20
20
  end
21
21
 
22
+ true
23
+ end
24
+
25
+ def run_hooks_for!(hook_family, context = nil)
26
+ run_hooks_for(hook_family, context)
22
27
  @hooks[hook_family] = []
23
28
 
24
29
  true
@@ -60,11 +60,13 @@ class Rage::Logger
60
60
  fatal: Logger::FATAL,
61
61
  unknown: Logger::UNKNOWN
62
62
  }
63
+ REDACTED = "[REDACTED]"
64
+ private_constant :REDACTED
63
65
 
64
66
  attr_reader :level, :formatter
65
67
 
66
68
  # @private
67
- attr_reader :dynamic_tags, :dynamic_context
69
+ attr_reader :dynamic_tags, :dynamic_context, :log_redact_keys
68
70
 
69
71
  # @private
70
72
  attr_reader :external_logger
@@ -132,6 +134,19 @@ class Rage::Logger
132
134
  rebuild!
133
135
  end
134
136
 
137
+ # @private
138
+ def log_redact_keys=(log_redact_keys)
139
+ if log_redact_keys&.any?
140
+ @log_redact_keys = log_redact_keys
141
+ @log_redact_keys_matcher = Regexp.union(log_redact_keys.map { |key| Regexp.new(Regexp.escape(key), Regexp::IGNORECASE) })
142
+ else
143
+ @log_redact_keys = nil
144
+ @log_redact_keys_matcher = nil
145
+ end
146
+
147
+ rebuild!
148
+ end
149
+
135
150
  # Add custom keys to an entry.
136
151
  #
137
152
  # @param context [Hash] a hash of custom keys
@@ -196,7 +211,7 @@ class Rage::Logger
196
211
  RUBY
197
212
  elsif @external_logger.is_a?(External::Static)
198
213
  # an object that implements Ruby's Logger interface is used as a logger
199
- write_call = <<~RUBY
214
+ write_call = build_redacted_context_call(<<~RUBY)
200
215
  @external_logger.wrapped.#{level_name}(
201
216
  #{build_formatter_call(level_name, level_val)}
202
217
  )
@@ -233,7 +248,7 @@ class Rage::Logger
233
248
  request_info: "Fiber[:__rage_logger_final].freeze"
234
249
  })
235
250
 
236
- write_call = <<~RUBY
251
+ write_call = build_redacted_context_call(<<~RUBY)
237
252
  @external_logger.wrapped.call(#{parameters})
238
253
  RUBY
239
254
 
@@ -253,7 +268,7 @@ class Rage::Logger
253
268
  end
254
269
  RUBY
255
270
  else
256
- write_call = <<~RUBY
271
+ write_call = build_redacted_context_call(<<~RUBY)
257
272
  @logdev.write(
258
273
  #{build_formatter_call(level_name, level_val)}
259
274
  )
@@ -296,6 +311,42 @@ class Rage::Logger
296
311
  end
297
312
  end
298
313
 
314
+ def build_redacted_context_call(write_call)
315
+ return write_call unless @log_redact_keys
316
+
317
+ <<~RUBY
318
+ with_redacted_context do
319
+ #{write_call}
320
+ end
321
+ RUBY
322
+ end
323
+
324
+ def with_redacted_context
325
+ context = Fiber[:__rage_logger_context]
326
+ return yield if context.nil? || context.empty?
327
+
328
+ Fiber[:__rage_logger_context] = redact_value(context)
329
+ yield
330
+ ensure
331
+ Fiber[:__rage_logger_context] = context
332
+ end
333
+
334
+ def redact_value(value)
335
+ if value.is_a?(Hash)
336
+ value.each_with_object({}) do |(key, nested_value), redacted|
337
+ redacted[key] = if @log_redact_keys_matcher.match?(key)
338
+ REDACTED
339
+ else
340
+ redact_value(nested_value)
341
+ end
342
+ end
343
+ elsif value.is_a?(Array)
344
+ value.map { |item| redact_value(item) }
345
+ else
346
+ value
347
+ end
348
+ end
349
+
299
350
  def with_dynamic_tags_and_context
300
351
  do_calls, end_calls = [], []
301
352
 
@@ -13,7 +13,7 @@
13
13
  <script>
14
14
  window.onload = () => {
15
15
  window.ui = SwaggerUIBundle({
16
- url: '<%= spec_url %>',
16
+ url: window.location.pathname.replace(/\/?$/, "/json"),
17
17
  dom_id: '#swagger-ui',
18
18
  });
19
19
  };
@@ -42,11 +42,9 @@ module Rage::OpenAPI
42
42
  # run Rage.openapi.application(namespace: "Api::V2")
43
43
  # end
44
44
  def self.application(namespace: nil)
45
- html_app = ->(env) do
45
+ html_app = ->(_) do
46
46
  __data_cache[[:page, namespace]] ||= begin
47
- scheme, host, path = env["rack.url_scheme"], env["HTTP_HOST"], env["SCRIPT_NAME"]
48
- spec_url = "#{scheme}://#{host}#{path}/json"
49
- page = ERB.new(File.read("#{__dir__}/index.html.erb")).result(binding)
47
+ page = ERB.new(File.read("#{__dir__}/index.html.erb")).result
50
48
 
51
49
  [200, { "content-type" => "text/html; charset=UTF-8" }, [page]]
52
50
  end
@@ -120,7 +118,7 @@ module Rage::OpenAPI
120
118
 
121
119
  # @private
122
120
  def self.__try_parse_collection(str)
123
- if str =~ /^Array<([\w\s:\(\),]+)>$/ || str =~ /^\[([\w\s:\(\),]+)\]$/
121
+ if str =~ /^Array<([\w\s:\(\),'\"]+)>$/ || str =~ /^\[([\w\s:\(\),'\"]+)\]$/
124
122
  [true, $1]
125
123
  else
126
124
  [false, str]
@@ -202,9 +200,20 @@ module Rage::OpenAPI
202
200
 
203
201
  # @private
204
202
  def self.__log_warn(log)
203
+ @__warnings << log if @__warnings
205
204
  puts "[OpenAPI] WARNING: #{log}"
206
205
  end
207
206
 
207
+ # @private
208
+ # @return [Array<String>] the warnings logged inside the block
209
+ def self.__collect_warnings
210
+ @__warnings = []
211
+ yield
212
+ @__warnings
213
+ ensure
214
+ @__warnings = nil
215
+ end
216
+
208
217
  module Nodes
209
218
  end
210
219
 
@@ -20,9 +20,10 @@ class Rage::OpenAPI::Parsers::Ext::Blueprinter
20
20
  is_collection, raw_klass_str, serializer_options = Rage::OpenAPI.__parse_serializer_args(klass_str)
21
21
  klass = @namespace.const_get(raw_klass_str)
22
22
  schema = build_schema(klass, is_collection, serializer_options)
23
+ registry_key = "#{raw_klass_str}_#{serializer_options[:view] || :default}"
23
24
 
24
- if @root.schema_registry.key?(raw_klass_str)
25
- @root.schema_registry[raw_klass_str] = is_collection ? schema["items"] : schema
25
+ if @root.schema_registry.key?(registry_key)
26
+ @root.schema_registry[registry_key] = is_collection ? schema["items"] : schema
26
27
  end
27
28
 
28
29
  schema
@@ -32,26 +33,31 @@ class Rage::OpenAPI::Parsers::Ext::Blueprinter
32
33
 
33
34
  private
34
35
 
35
- def build_schema(klass, is_collection, serializer_options = nil)
36
- @parsing_stack.add(klass.name)
37
-
38
- view_name = serializer_options&.key?(:view) ? serializer_options[:view] : :default
36
+ def build_schema(klass, is_collection, serializer_options)
37
+ view_name = serializer_options&.key?(:view) ? serializer_options[:view].to_sym : :default
39
38
  reflections = klass.reflections
40
39
  view = reflections[view_name]
41
40
  raise InvalidViewError, "invalid view #{view_name}" unless view
42
41
 
42
+ @parsing_stack.add([klass.name, view_name])
43
+
43
44
  identifier_fields = extract_fields(reflections[:identifier])
44
45
  default_fields = extract_fields(view)
45
46
  association_fields = extract_associations(view)
46
47
 
47
- @parsing_stack.delete(klass.name)
48
+ @parsing_stack.delete([klass.name, view_name])
48
49
 
49
50
  schema = identifier_fields.merge(default_fields.merge(association_fields).sort.to_h)
50
51
 
51
52
  result = { "type" => "object" }
52
53
  result["properties"] = schema if schema.any?
53
54
  result = { "type" => "array", "items" => result } if is_collection
54
- result
55
+
56
+ if serializer_options&.key?(:root)
57
+ { "type" => "object", "properties" => { serializer_options[:root].to_s => result } }
58
+ else
59
+ result
60
+ end
55
61
  end
56
62
 
57
63
  def extract_fields(view)
@@ -65,14 +71,16 @@ class Rage::OpenAPI::Parsers::Ext::Blueprinter
65
71
  blueprint = association.blueprint
66
72
  name, display_name = association.name.to_s, association.display_name.to_s
67
73
  is_collection = collection_association?(name)
74
+ serializer_options = { view: association.view }
68
75
 
69
76
  item_schema = if blueprint.is_a?(Proc)
70
77
  { "type" => "object" }
71
- elsif @parsing_stack.include?(blueprint.name)
72
- @root.schema_registry[blueprint.name] ||= nil
73
- { "$ref" => "#/components/schemas/#{blueprint.name}" }
78
+ elsif @parsing_stack.include?([blueprint.name, association.view])
79
+ schema_key = "#{blueprint.name}_#{association.view}"
80
+ @root.schema_registry[schema_key] ||= nil
81
+ { "$ref" => "#/components/schemas/#{schema_key}" }
74
82
  else
75
- build_schema(blueprint, false)
83
+ build_schema(blueprint, false, serializer_options)
76
84
  end
77
85
 
78
86
  properties[display_name] = is_collection ? { "type" => "array", "items" => item_schema } : item_schema
@@ -1,6 +1,6 @@
1
1
  This is an almost complete rewrite of https://github.com/delvedor/find-my-way.
2
2
 
3
- Currently, the only constraint supported is the `host` constraint. Regexp constraints are likely to be added. Custom/lambda constraints are unlikely to be added.
3
+ Currently, the only constraint supported is the `host` constraint. The constraint value can be either a string or a regular expression. Custom/lambda constraints are unlikely to be added.
4
4
 
5
5
  Compared to the Rails router, the most notable difference except constraints is that a wildcard segment can only be in the last section of the path and cannot be named.
6
6
 
@@ -35,13 +35,17 @@ class Rage::Router::Backend
35
35
  # by the time the app is called, `rack.input` is already consumed in `Rage::ParamsParser`
36
36
  env["rack.input"].rewind
37
37
 
38
- env["SCRIPT_NAME"] = path
39
- sub_path = env["PATH_INFO"].delete_prefix!(path)
40
- env["PATH_INFO"] = "/" if sub_path == ""
38
+ script_name = env["SCRIPT_NAME"]
39
+ path_info = env["PATH_INFO"]
40
+
41
+ env["SCRIPT_NAME"] = "#{script_name}#{path}"
42
+ sub_path = path_info.delete_prefix(path)
43
+ env["PATH_INFO"] = sub_path == "" ? "/" : sub_path
41
44
 
42
45
  handler.call(env)
43
46
  ensure
44
- env["PATH_INFO"] = "#{env["SCRIPT_NAME"]}#{sub_path}"
47
+ env["SCRIPT_NAME"] = script_name
48
+ env["PATH_INFO"] = path_info
45
49
  end
46
50
 
47
51
  methods.each do |method|
@@ -420,11 +420,11 @@ class Rage::Router::DSL
420
420
  at = at.delete_suffix("/") if at.end_with?("/")
421
421
 
422
422
  http_methods = if via == :all || via.nil?
423
- @default_match_methods.map { |method| method.to_s.upcase! }
423
+ @default_match_methods.map { |method| method.to_s.upcase }
424
424
  else
425
- Array(via).map! do |method|
425
+ Array(via).map do |method|
426
426
  raise ArgumentError, "Invalid HTTP method: #{method}" unless @default_match_methods.include?(method)
427
- method.to_s.upcase!
427
+ method.to_s.upcase
428
428
  end
429
429
  end
430
430
 
@@ -501,8 +501,7 @@ class Rage::Router::DSL
501
501
  end
502
502
 
503
503
  def to_singular(str)
504
- @active_support_loaded ||= str.respond_to?(:singularize) || :false
505
- return str.singularize if @active_support_loaded != :false
504
+ return str.singularize if str.respond_to?(:singularize)
506
505
 
507
506
  @endings ||= {
508
507
  "ves" => "fe",
@@ -519,8 +518,7 @@ class Rage::Router::DSL
519
518
  end
520
519
 
521
520
  def to_plural(str)
522
- @active_support_loaded ||= str.respond_to?(:pluralize) || :false
523
- return str.pluralize if @active_support_loaded != :false
521
+ return str.pluralize if str.respond_to?(:pluralize)
524
522
 
525
523
  str.end_with?("s") ? str : "#{str}s"
526
524
  end
data/lib/rage/tasks.rb CHANGED
@@ -7,6 +7,7 @@ class Rage::Tasks
7
7
  class << self
8
8
  def init
9
9
  load_db_tasks if defined?(StandaloneMigrations)
10
+ load_rage_tasks
10
11
  load_app_tasks
11
12
  end
12
13
 
@@ -40,5 +41,9 @@ class Rage::Tasks
40
41
  def load_app_tasks
41
42
  Dir[Rage.root.join("lib/tasks/**/*.rake")].each { |file| load file }
42
43
  end
44
+
45
+ def load_rage_tasks
46
+ Dir[File.expand_path("tasks/**/*.rake", __dir__)].each { |file| load file }
47
+ end
43
48
  end
44
49
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rage::Telemetry
4
+ ##
5
+ # The `Rage::Telemetry::Capacity` module exposes framework-owned runtime
6
+ # state - metrics describing the server's current load and resource utilization
7
+ # that cannot be derived from existing spans.
8
+ #
9
+ # Unlike spans, capacity metrics aren't tied to a specific operation or
10
+ # event, they reflect the server state at the moment they are read, and
11
+ # are meant to be sampled periodically rather than triggered by handlers.
12
+ #
13
+ # Combine with {Rage::Telemetry.every} to report a metric on a fixed
14
+ # interval:
15
+ #
16
+ # Rage::Telemetry.every(1000) do
17
+ # MyMetrics.gauge("server.queued_connections", Rage::Telemetry::Capacity.queued_connections)
18
+ # end
19
+ #
20
+ # @see Rage::Telemetry.every
21
+ #
22
+ module Capacity
23
+ class << self
24
+ # Returns the number of established connections currently waiting in the
25
+ # kernel accept queue for the server listening sockets. This is the
26
+ # count of clients that have already completed the TCP handshake but
27
+ # haven't yet been picked up by the application via `accept()`.
28
+ #
29
+ # A value that stays bigger than 0 is a sign the server isn't accepting
30
+ # connections fast enough to keep up with incoming traffic.
31
+ #
32
+ # @raise [NotImplementedError] on non-Linux systems
33
+ # @return [Integer] the accept-queue depth
34
+ def queued_connections
35
+ accept_queue_depth = Iodine::Perf.queued_connections
36
+ raise NotImplementedError if accept_queue_depth.nil?
37
+
38
+ accept_queue_depth
39
+ end
40
+ end
41
+ end
42
+ end
@@ -36,6 +36,24 @@ module Rage::Telemetry
36
36
  __registry.keys
37
37
  end
38
38
 
39
+ # Registers a block to be executed repeatedly at a fixed interval while the server is running.
40
+ # The block runs inside a fiber, so blocking I/O inside it (e.g. flushing metrics to a
41
+ # collector) will not block the server.
42
+ #
43
+ # @param interval_ms [Integer] the execution interval in milliseconds
44
+ # @example Periodically sample GC stats
45
+ # Rage::Telemetry.every(1000) { MyMetrics.record_gc_stats(GC.stat) }
46
+ def self.every(interval_ms, &block)
47
+ Iodine.run_every(interval_ms) do
48
+ Fiber.schedule do
49
+ block.call
50
+ rescue => e
51
+ Rage.logger.error("#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
52
+ Rage::Errors.report(e)
53
+ end
54
+ end
55
+ end
56
+
39
57
  # @private
40
58
  def self.__registry
41
59
  @__registry ||= Spans.constants.each_with_object({}) do |const, memo|
@@ -121,4 +139,5 @@ end
121
139
 
122
140
  require_relative "tracer"
123
141
  require_relative "handler"
142
+ require_relative "capacity"
124
143
  Dir["#{__dir__}/spans/*.rb"].each { |span| require_relative span }
data/lib/rage/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rage
4
- VERSION = "1.26.1"
4
+ VERSION = "1.28.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -182,6 +182,9 @@ module Rage
182
182
  alias_method :configuration, :config
183
183
  end
184
184
 
185
+ module CLI
186
+ end
187
+
185
188
  module Router
186
189
  module Strategies
187
190
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.26.1
4
+ version: 1.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '5.5'
46
+ version: '5.6'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '5.5'
53
+ version: '5.6'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: zeitwerk
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -152,6 +152,10 @@ files:
152
152
  - lib/rage/cable/protocols/raw_web_socket_json.rb
153
153
  - lib/rage/cable/router.rb
154
154
  - lib/rage/cli.rb
155
+ - lib/rage/cli/base.rb
156
+ - lib/rage/cli/code_generator.rb
157
+ - lib/rage/cli/new_app_generator.rb
158
+ - lib/rage/cli/openapi.rb
155
159
  - lib/rage/cli/skills.rb
156
160
  - lib/rage/code_loader.rb
157
161
  - lib/rage/configuration.rb
@@ -233,6 +237,7 @@ files:
233
237
  - lib/rage/sse/sse.rb
234
238
  - lib/rage/sse/stream.rb
235
239
  - lib/rage/tasks.rb
240
+ - lib/rage/telemetry/capacity.rb
236
241
  - lib/rage/telemetry/handler.rb
237
242
  - lib/rage/telemetry/spans/await_fiber.rb
238
243
  - lib/rage/telemetry/spans/broadcast_cable_stream.rb