active_job_status 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: d6b9ef8fd6afc49dd42ddff3b89715f7f0bc3c24
4
- data.tar.gz: d577c498e486282b0d39346925321696632c2a63
3
+ metadata.gz: 5431b8cc6fed8a99592123c01fc9357cf4a46192
4
+ data.tar.gz: bd92db0a996e02e8ac1cabdb1af6d9e8092de953
5
5
  SHA512:
6
- metadata.gz: 296968828f08cbc8aea202ee67cd135337ad2722ddd7f6c8c9eb69aa2bc23f6dcac85ccb273daa299f1165e6d88db70a857ad9727637a44a8952ff7e73c6ce18
7
- data.tar.gz: 6dc29bb46f45a5e91a839a595c05687fd18a01dbecd626d6c194116c7a5f1b4a9159ba94ce3833f18585b606f5e5b30e9398a0b84bcd5264ace27b2fe799a7e7
6
+ metadata.gz: 1e081751d977356a0315b686321c526e02ddc881b5e8624c59434a187bb40625aa452ca31188a6d7905550eadb93dd0c2f8c03d02390c3c3913f61e4da730480
7
+ data.tar.gz: 88df222fab4619634fe7073fc024b691c649bbd22df91c5261a9f304e3eaff9117f35ab2a05ff903e6c303a002650f5baa86884c8fd74758f51868a85daefb0f
data/.gitignore CHANGED
@@ -15,3 +15,4 @@
15
15
  mkmf.log
16
16
  .DS_Store
17
17
  *.swp
18
+ *.gem
data/.travis.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  language: ruby
2
2
  cache: bundler
3
3
  rvm:
4
- - 2.2.0
4
+ - 2.3.1
5
5
  script: bundle exec rake
6
6
  services:
7
7
  - redis
data/CHANGELOG.md CHANGED
@@ -1,4 +1,8 @@
1
1
  # ActiveJobStatus
2
+ ## 1.2.0
3
+ - Add support for Rails 5
4
+ - Adds many small improvements (see https://github.com/cdale77/active_job_status/pull/15)
5
+
2
6
  ## 1.1.0
3
7
  - Add support for Redis via the Readthis gem
4
8
 
data/README.md CHANGED
@@ -46,6 +46,8 @@ ActiveJob status will detect Redis and use some nice optimizations.
46
46
 
47
47
  # config/initializers/active_job_status.rb
48
48
  ActiveJobStatus.store = ActiveSupport::Cache::RedisStore.new
49
+ # or if you are using https://github.com/sorentwo/readthis
50
+ ActiveJobStatus.store = ActiveSupport::Cache::ReadthisStore.new
49
51
 
50
52
  ## Usage
51
53
 
@@ -58,6 +60,24 @@ upgrading from versions < 1.0, you may need to update your code.*
58
60
  class MyJob < ActiveJobStatus::TrackableJob
59
61
  end
60
62
 
63
+ Or you can just include ActiveJobStatus::Hooks into your job if you are using
64
+ version 1.2 or greater. You might want to use this approach if you are using an
65
+ `ApplicationJob` and do not want all your jobs to be trackable.
66
+
67
+ class MyJob < ActiveJob::Base
68
+ include ActiveJobStatus::Hooks
69
+ end
70
+
71
+ or
72
+
73
+ class MyJob < ApplicationJob
74
+ include ActiveJobStatus::Hooks
75
+ end
76
+
77
+ class ApplicationJob < ActiveJob::Base
78
+ end
79
+
80
+
61
81
  ### Job Status
62
82
 
63
83
  Check the status of a job using the ActiveJob job_id. Status of a job will only
@@ -65,8 +85,12 @@ be available for 72 hours after the job is queued. For right now you can't
65
85
  change that.
66
86
 
67
87
  my_job = MyJob.perform_later
68
- ActiveJobStatus::JobStatus.get_status(job_id: my_job.job_id)
69
- # => :queued, :working, :complete
88
+ job_status = ActiveJobStatus.fetch(my_job.job_id)
89
+ job_status.queued?
90
+ job_status.working?
91
+ job_status.completed?
92
+ job_status.status
93
+ # => :queued, :working, :completed, nil
70
94
 
71
95
  ### Job Batches
72
96
  For job batches you an use any key you want (for example, you might use a
@@ -106,7 +130,8 @@ You can ask the batch for other bits of information:
106
130
  You can also search for batches:
107
131
  ActiveJobStatus::JobBatch.find(batch_id: my_key)
108
132
 
109
- This method will return nil no associated job ids can be found, otherwise it will
133
+ This method will return nil if no associated job ids can be found, otherwise it will
134
+ This method will return nil no associated job ids can be found, otherwise it will
110
135
  return an ActiveJobStatus::JobBatch object.
111
136
 
112
137
  ## Contributing
@@ -23,8 +23,8 @@ Gem::Specification.new do |spec|
23
23
  spec.add_development_dependency "rspec", "~> 3.0"
24
24
  spec.add_development_dependency "codeclimate-test-reporter", "~> 0.4"
25
25
 
26
- spec.add_runtime_dependency "activejob", "~>4.2"
27
- spec.add_runtime_dependency "activesupport", "~>4.2"
26
+ spec.add_runtime_dependency "activejob", "> 4.2"
27
+ spec.add_runtime_dependency "activesupport", "> 4.2"
28
28
 
29
29
  spec.post_install_message = "If updating from a version below 1.0, please note " \
30
30
  "TrackabeJob is now namespaced inside of ActiveJob. " \
@@ -1,3 +1,4 @@
1
+ require "active_job_status/hooks"
1
2
  require "active_job_status/trackable_job"
2
3
  require "active_job_status/job_tracker"
3
4
  require "active_job_status/job_status"
@@ -7,7 +8,15 @@ require "active_job_status/configure_redis" if defined? Rails
7
8
 
8
9
  module ActiveJobStatus
9
10
  class << self
10
- attr_accessor :store
11
+ attr_accessor :store, :expiration
12
+
13
+ def get_status(job_id)
14
+ fetch(job_id).status
15
+ end
16
+
17
+ def fetch(job_id)
18
+ status = store.fetch(job_id)
19
+ JobStatus.new(status)
20
+ end
11
21
  end
12
22
  end
13
-
@@ -1,7 +1,7 @@
1
1
  require 'rails'
2
2
  class ConfigureRedis < Rails::Railtie
3
3
  initializer "configure_redis.configure_rails_initializers" do
4
- if defined? ActiveSupport::Cache::RedisStore
4
+ if defined? ActiveSupport::Cache::RedisStore || defined? ActiveSupport::Cache::ReadthisStore
5
5
  require "active_job_status/redis"
6
6
  end
7
7
  end
@@ -0,0 +1,19 @@
1
+ module ActiveJobStatus
2
+ module Hooks
3
+ def self.included(base)
4
+ base.class_eval do
5
+ before_enqueue { job_tracker.enqueued }
6
+
7
+ before_perform { job_tracker.performing }
8
+
9
+ after_perform { job_tracker.completed }
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def job_tracker
16
+ @job_tracker ||= ActiveJobStatus::JobTracker.new(job_id: job_id)
17
+ end
18
+ end
19
+ end
@@ -14,7 +14,7 @@ module ActiveJobStatus
14
14
 
15
15
  def store_data(expire_in:)
16
16
  ActiveJobStatus.store.delete(@batch_id) # delete any old batches
17
- if ActiveJobStatus.store.class.to_s == "ActiveSupport::Cache::RedisStore"
17
+ if ["ActiveSupport::Cache::RedisStore", "ActiveSupport::Cache::ReadthisStore"].include? ActiveJobStatus.store.class.to_s
18
18
  ActiveJobStatus.store.sadd(@batch_id, @job_ids)
19
19
  ActiveJobStatus.store.expire(@batch_id, expire_in)
20
20
  else
@@ -24,7 +24,7 @@ module ActiveJobStatus
24
24
 
25
25
  def add_jobs(job_ids:)
26
26
  @job_ids = @job_ids + job_ids
27
- if ActiveJobStatus.store.class.to_s == "ActiveSupport::Cache::RedisStore"
27
+ if ["ActiveSupport::Cache::RedisStore", "ActiveSupport::Cache::ReadthisStore"].include? ActiveJobStatus.store.class.to_s
28
28
  # Save an extra redis query and perform atomic operation
29
29
  ActiveJobStatus.store.sadd(@batch_id, job_ids)
30
30
  else
@@ -34,15 +34,14 @@ module ActiveJobStatus
34
34
  end
35
35
 
36
36
  def completed?
37
- job_statuses = []
38
- @job_ids.each do |job_id|
39
- job_statuses << ActiveJobStatus::JobStatus.get_status(job_id: job_id)
40
- end
41
- !job_statuses.any?
37
+ @job_ids.map do |job_id|
38
+ job_status = ActiveJobStatus.get_status(job_id)
39
+ job_status == nil || job_status == :completed
40
+ end.any?
42
41
  end
43
42
 
44
43
  def self.find(batch_id:)
45
- if ActiveJobStatus.store.class.to_s == "ActiveSupport::Cache::RedisStore"
44
+ if ["ActiveSupport::Cache::RedisStore", "ActiveSupport::Cache::ReadthisStore"].include? ActiveJobStatus.store.class.to_s
46
45
  job_ids = ActiveJobStatus.store.smembers(batch_id)
47
46
  else
48
47
  job_ids = ActiveJobStatus.store.fetch(batch_id).to_a
@@ -62,4 +61,3 @@ module ActiveJobStatus
62
61
  end
63
62
  end
64
63
  end
65
-
@@ -1,11 +1,29 @@
1
1
  module ActiveJobStatus
2
- module JobStatus
3
- # Provides a way to check on the status of a given job
2
+ class JobStatus
3
+ ENQUEUED = :queued
4
+ WORKING = :working
5
+ COMPLETED = :completed
4
6
 
5
- def self.get_status(job_id:)
6
- status = ActiveJobStatus.store.fetch(job_id)
7
- status ? status.to_sym : nil
7
+ attr_reader :status
8
+
9
+ def initialize(status)
10
+ @status = status && status.to_sym
11
+ end
12
+
13
+ def queued?
14
+ status == ENQUEUED
15
+ end
16
+
17
+ def working?
18
+ status == WORKING
19
+ end
20
+
21
+ def completed?
22
+ status == COMPLETED
23
+ end
24
+
25
+ def empty?
26
+ status.nil?
8
27
  end
9
28
  end
10
29
  end
11
-
@@ -1,17 +1,41 @@
1
1
  module ActiveJobStatus
2
- module JobTracker
3
- # Provides methods to CRUD job status records in Redis
2
+ class JobTracker
3
+ DEFAULT_EXPIRATION = 72.hours.freeze
4
4
 
5
- def self.enqueue(job_id:)
6
- ActiveJobStatus.store.write(job_id, "queued", expires_in: 259200)
5
+ def initialize(job_id:, store: ActiveJobStatus.store, expiration: ActiveJobStatus.expiration)
6
+ @job_id = job_id
7
+ @store = store
8
+ @expiration = expiration
7
9
  end
8
10
 
9
- def self.update(job_id:, status:)
10
- ActiveJobStatus.store.write(job_id, status.to_s)
11
+ def enqueued
12
+ store.write(
13
+ job_id,
14
+ JobStatus::ENQUEUED.to_s,
15
+ expires_in: expiration || DEFAULT_EXPIRATION
16
+ )
11
17
  end
12
18
 
13
- def self.remove(job_id:)
14
- ActiveJobStatus.store.delete(job_id)
19
+ def performing
20
+ store.write(
21
+ job_id,
22
+ JobStatus::WORKING.to_s
23
+ )
15
24
  end
25
+
26
+ def completed
27
+ store.write(
28
+ job_id,
29
+ JobStatus::COMPLETED.to_s
30
+ )
31
+ end
32
+
33
+ def deleted
34
+ store.delete(job_id)
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :job_id, :store, :expiration
16
40
  end
17
41
  end
@@ -14,7 +14,12 @@ module ActiveJobStatus
14
14
  end
15
15
  end
16
16
 
17
- ActiveSupport::Cache::RedisStore.include(
18
- ActiveJobStatus::Redis
19
- )
20
-
17
+ if defined? ActiveSupport::Cache::RedisStore
18
+ ActiveSupport::Cache::RedisStore.include(
19
+ ActiveJobStatus::Redis
20
+ )
21
+ elsif defined? ActiveSupport::Cache::ReadthisStore
22
+ ActiveSupport::Cache::ReadthisStore.include(
23
+ ActiveJobStatus::Redis
24
+ )
25
+ end
@@ -1,11 +1,8 @@
1
1
  require "active_job"
2
+ require "active_job_status/hooks"
3
+
2
4
  module ActiveJobStatus
3
5
  class TrackableJob < ActiveJob::Base
4
-
5
- before_enqueue { ActiveJobStatus::JobTracker.enqueue(job_id: @job_id) }
6
-
7
- before_perform { ActiveJobStatus::JobTracker.update(job_id: @job_id, status: :working) }
8
-
9
- after_perform { ActiveJobStatus::JobTracker.remove(job_id: @job_id) }
6
+ include ActiveJobStatus::Hooks
10
7
  end
11
8
  end
@@ -1,3 +1,3 @@
1
1
  module ActiveJobStatus
2
- VERSION = "1.1.0"
2
+ VERSION = "1.2.0"
3
3
  end
@@ -57,7 +57,11 @@ describe ActiveJobStatus::JobBatch do
57
57
  update_store(id_array: total_jobs, job_status: :working)
58
58
  expect(batch.completed?).to be_falsey
59
59
  end
60
- it "should be true when jobs are completed" do
60
+ it "should be true when jobs are all completed" do
61
+ update_store(id_array: total_jobs, job_status: :completed)
62
+ expect(batch.completed?).to be_truthy
63
+ end
64
+ it "should be true when jobs are not in the store" do
61
65
  clear_store(id_array: total_jobs)
62
66
  expect(batch.completed?).to be_truthy
63
67
  end
@@ -1,26 +1,53 @@
1
1
  require "spec_helper"
2
2
 
3
3
  describe ActiveJobStatus::JobStatus do
4
+ let(:job_status) { described_class.new(status) }
4
5
 
5
- describe "::get_status" do
6
+ context 'when queued' do
7
+ let(:status) { 'queued' }
6
8
 
7
- describe "for a queued job" do
8
- let(:job) { ActiveJobStatus::TrackableJob.new.enqueue }
9
+ it 'returns the correct state' do
10
+ expect(job_status.queued?).to eq true
11
+ expect(job_status.working?).to eq false
12
+ expect(job_status.completed?).to eq false
13
+ expect(job_status.empty?).to eq false
14
+ expect(job_status.status).to eq :queued
15
+ end
16
+ end
17
+
18
+ context 'when working' do
19
+ let(:status) { 'working' }
9
20
 
10
- it "should return :queued" do
11
- expect(ActiveJobStatus::JobStatus.get_status(job_id: job.job_id)).to eq :queued
12
- end
21
+ it 'returns the correct state' do
22
+ expect(job_status.queued?).to eq false
23
+ expect(job_status.working?).to eq true
24
+ expect(job_status.completed?).to eq false
25
+ expect(job_status.empty?).to eq false
26
+ expect(job_status.status).to eq :working
13
27
  end
28
+ end
14
29
 
15
- describe "for a complete job" do
30
+ context 'when completed' do
31
+ let(:status) { 'completed' }
16
32
 
17
- let!(:job) { ActiveJobStatus::TrackableJob.perform_later }
18
- sleep(10)
19
- #clear_performed_jobs
20
- it "should return :complete", pending: true do
21
- expect(ActiveJobStatus::JobStatus.get_status(job_id: job.job_id)).to be_nil
22
- end
33
+ it 'returns the correct state' do
34
+ expect(job_status.queued?).to eq false
35
+ expect(job_status.working?).to eq false
36
+ expect(job_status.completed?).to eq true
37
+ expect(job_status.empty?).to eq false
38
+ expect(job_status.status).to eq :completed
23
39
  end
24
40
  end
25
- end
26
41
 
42
+ context 'when nil' do
43
+ let(:status) { nil }
44
+
45
+ it 'returns the correct state' do
46
+ expect(job_status.queued?).to eq false
47
+ expect(job_status.working?).to eq false
48
+ expect(job_status.completed?).to eq false
49
+ expect(job_status.empty?).to eq true
50
+ expect(job_status.status).to eq nil
51
+ end
52
+ end
53
+ end
@@ -1,29 +1,53 @@
1
1
  require "spec_helper"
2
2
 
3
3
  describe ActiveJobStatus::JobTracker do
4
-
5
4
  let!(:store) { ActiveJobStatus.store = new_store }
6
- let(:job) { ActiveJobStatus::TrackableJob.new.enqueue }
5
+ let(:job_id) { 'j0b-1d' }
6
+ let(:tracker) { described_class.new(job_id: job_id) }
7
+
8
+ describe "#enqueued" do
9
+ it "starts tracking the job" do
10
+ tracker.enqueued
11
+ expect(store.fetch(job_id)).to eq "queued"
12
+ end
7
13
 
8
- describe "::enqueue" do
9
- it "should enqueue a job" do
10
- ActiveJobStatus::JobTracker.enqueue(job_id: job.job_id)
11
- expect(store.fetch(job.job_id)).to eq "queued"
14
+ context 'with default expiration period' do
15
+ before { ActiveJobStatus.expiration = nil }
16
+
17
+ it 'expires in 72 hours' do
18
+ expect(store).to receive(:write).with(job_id, "queued", expires_in: 72.hours)
19
+ tracker.enqueued
20
+ end
21
+ end
22
+
23
+ context 'with default expiration period' do
24
+ before { ActiveJobStatus.expiration = 10.seconds }
25
+
26
+ it 'expires in the given period' do
27
+ expect(store).to receive(:write).with(job_id, "queued", expires_in: 10.seconds)
28
+ tracker.enqueued
29
+ end
12
30
  end
13
31
  end
14
32
 
15
- describe "::update" do
16
- it "should update a job status" do
17
- ActiveJobStatus::JobTracker.update(job_id: job.job_id, status: :working)
18
- expect(store.fetch(job.job_id)).to eq "working"
33
+ describe "#performing" do
34
+ it "updates the job status" do
35
+ tracker.performing
36
+ expect(store.fetch(job_id)).to eq "working"
19
37
  end
20
38
  end
21
39
 
22
- describe "::remove" do
23
- it "should remove the job from the cache store" do
24
- ActiveJobStatus::JobTracker.remove(job_id: job.job_id)
25
- expect(store.fetch(job.job_id)).to eq nil
40
+ describe "#completed" do
41
+ it "updates the job status" do
42
+ tracker.completed
43
+ expect(store.fetch(job_id)).to eq "completed"
26
44
  end
27
45
  end
28
- end
29
46
 
47
+ describe "#deleted" do
48
+ it "removes the job from the store" do
49
+ tracker.deleted
50
+ expect(store.fetch(job_id)).to eq nil
51
+ end
52
+ end
53
+ end
data/spec/spec_helper.rb CHANGED
@@ -13,6 +13,6 @@ RSpec.configure do |c|
13
13
  c.include Helpers
14
14
  end
15
15
 
16
- if defined? ActiveSupport::Cache::RedisStore
16
+ if defined? ActiveSupport::Cache::RedisStore || defined? ActiveSupport::Cache::ReadthisStore
17
17
  require "active_job_status/redis"
18
18
  end
@@ -3,6 +3,9 @@ module Helpers
3
3
  if defined? ActiveSupport::Cache::RedisStore
4
4
  puts "Using RedisStore"
5
5
  ActiveSupport::Cache::RedisStore.new
6
+ elsif defined? ActiveSupport::Cache::ReadthisStore
7
+ puts "Using ReadthisStore"
8
+ ActiveSupport::Cache::ReadthisStore.new
6
9
  else
7
10
  puts "Using MemoryStore"
8
11
  ActiveSupport::Cache::MemoryStore.new
@@ -1,4 +1,4 @@
1
- if defined? ActiveSupport::Cache::RedisStore
1
+ if defined? ActiveSupport::Cache::RedisStore || defined? ActiveSupport::Cache::ReadthisStore
2
2
  # For redis we need to sleep to test
3
3
  def travel(interval)
4
4
  sleep interval
@@ -1,21 +1,40 @@
1
1
  require "spec_helper"
2
2
 
3
3
  describe ActiveJobStatus::TrackableJob do
4
+ class DummyJob < ActiveJobStatus::TrackableJob
5
+ def perform; end;
6
+ end
4
7
 
5
- describe "#initialize" do
6
-
7
- let(:trackable_job) { ActiveJobStatus::TrackableJob.new }
8
+ let(:job) { DummyJob.new }
8
9
 
9
- it "should create an object" do
10
- expect(trackable_job).to be_an_instance_of ActiveJobStatus::TrackableJob
10
+ describe 'queueing' do
11
+ it "should have a job id" do
12
+ expect(job.job_id).to_not be_blank
11
13
  end
12
14
  end
13
15
 
14
- describe 'queueing' do
15
- let(:trackable_job) { ActiveJobStatus::TrackableJob.new.enqueue }
16
+ describe 'tracking hooks' do
17
+ let(:job_tracker) { instance_double(ActiveJobStatus::JobTracker) }
16
18
 
17
- it "should have a job id" do
18
- expect(trackable_job.job_id).to_not be_blank
19
+ before do
20
+ allow(job).to receive(:job_id) { 'j0b-1d' }
21
+ end
22
+
23
+ describe 'before enqueue' do
24
+ it 'starts to track the job with JobTracker' do
25
+ expect(ActiveJobStatus::JobTracker).to receive(:new).with(job_id: 'j0b-1d') { job_tracker }
26
+ expect(job_tracker).to receive(:enqueued)
27
+ job.enqueue
28
+ end
29
+ end
30
+
31
+ describe 'before/after perform' do
32
+ it 'updates the job status with JobTracker and then remove it' do
33
+ expect(ActiveJobStatus::JobTracker).to receive(:new).with(job_id: 'j0b-1d') { job_tracker }
34
+ expect(job_tracker).to receive(:performing)
35
+ expect(job_tracker).to receive(:completed)
36
+ job.perform_now
37
+ end
19
38
  end
20
39
  end
21
40
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_job_status
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brad Johnson
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-05-09 00:00:00.000000000 Z
11
+ date: 2016-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -70,28 +70,28 @@ dependencies:
70
70
  name: activejob
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
- - - "~>"
73
+ - - ">"
74
74
  - !ruby/object:Gem::Version
75
75
  version: '4.2'
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
- - - "~>"
80
+ - - ">"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '4.2'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: activesupport
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
- - - "~>"
87
+ - - ">"
88
88
  - !ruby/object:Gem::Version
89
89
  version: '4.2'
90
90
  type: :runtime
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
- - - "~>"
94
+ - - ">"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '4.2'
97
97
  description: Job status and batches for ActiveJob. Create trackable jobs, check their
@@ -114,6 +114,7 @@ files:
114
114
  - gemfiles/redis-activesupport.gemfile
115
115
  - lib/active_job_status.rb
116
116
  - lib/active_job_status/configure_redis.rb
117
+ - lib/active_job_status/hooks.rb
117
118
  - lib/active_job_status/job_batch.rb
118
119
  - lib/active_job_status/job_status.rb
119
120
  - lib/active_job_status/job_tracker.rb