sidekiq-status 0.7.0 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +3 -1
- data/.travis.yml +4 -1
- data/Appraisals +11 -0
- data/CHANGELOG.md +18 -7
- data/README.md +99 -44
- data/Rakefile +2 -0
- data/gemfiles/sidekiq_3.x.gemfile +7 -0
- data/gemfiles/sidekiq_4.x.gemfile +7 -0
- data/gemfiles/sidekiq_5.x.gemfile +7 -0
- data/lib/sidekiq-status.rb +4 -3
- data/lib/sidekiq-status/client_middleware.rb +28 -7
- data/lib/sidekiq-status/server_middleware.rb +63 -16
- data/lib/sidekiq-status/sidekiq_extensions.rb +7 -0
- data/lib/sidekiq-status/testing/inline.rb +4 -0
- data/lib/sidekiq-status/version.rb +1 -1
- data/lib/sidekiq-status/web.rb +68 -24
- data/lib/sidekiq-status/worker.rb +4 -2
- data/sidekiq-status.gemspec +5 -3
- data/spec/lib/sidekiq-status/client_middleware_spec.rb +16 -10
- data/spec/lib/sidekiq-status/server_middleware_spec.rb +24 -10
- data/spec/lib/sidekiq-status/web_spec.rb +1 -1
- data/spec/lib/sidekiq-status/worker_spec.rb +1 -1
- data/spec/lib/sidekiq-status_spec.rb +40 -12
- data/spec/spec_helper.rb +54 -22
- data/spec/support/test_jobs.rb +17 -3
- data/web/views/status.erb +10 -10
- data/web/views/statuses.erb +75 -16
- metadata +40 -5
@@ -1,6 +1,17 @@
|
|
1
|
+
if Sidekiq.major_version < 5
|
2
|
+
require 'sidekiq/middleware/server/retry_jobs'
|
3
|
+
else
|
4
|
+
require 'sidekiq/job_retry'
|
5
|
+
end
|
6
|
+
|
1
7
|
module Sidekiq::Status
|
2
|
-
# Should be in the server middleware chain
|
8
|
+
# Should be in the server middleware chain
|
3
9
|
class ServerMiddleware
|
10
|
+
|
11
|
+
DEFAULT_MAX_RETRY_ATTEMPTS = Sidekiq.major_version < 5 ?
|
12
|
+
Sidekiq::Middleware::Server::RetryJobs::DEFAULT_MAX_RETRY_ATTEMPTS :
|
13
|
+
Sidekiq::JobRetry::DEFAULT_MAX_RETRY_ATTEMPTS
|
14
|
+
|
4
15
|
include Storage
|
5
16
|
|
6
17
|
# Parameterized initialization, use it when adding middleware to server chain
|
@@ -22,28 +33,64 @@ module Sidekiq::Status
|
|
22
33
|
# @param [Array] msg job args, should have jid format
|
23
34
|
# @param [String] queue queue name
|
24
35
|
def call(worker, msg, queue)
|
25
|
-
|
26
|
-
#
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
36
|
+
|
37
|
+
# Initial assignment to prevent SystemExit & co. from excepting
|
38
|
+
expiry = @expiration
|
39
|
+
|
40
|
+
# Determine the actual job class
|
41
|
+
klass = msg["args"][0]["job_class"] || msg["class"] rescue msg["class"]
|
42
|
+
job_class = klass.is_a?(Class) ? klass : Module.const_get(klass)
|
43
|
+
|
44
|
+
# Bypass unless this is a Sidekiq::Status::Worker job
|
45
|
+
unless job_class.ancestors.include?(Sidekiq::Status::Worker)
|
46
|
+
yield
|
47
|
+
return
|
34
48
|
end
|
35
49
|
|
36
|
-
|
50
|
+
# Determine job expiration
|
51
|
+
expiry = job_class.new.expiration || @expiration rescue @expiration
|
52
|
+
|
53
|
+
store_status worker.jid, :working, expiry
|
37
54
|
yield
|
38
|
-
store_status worker.jid, :complete,
|
55
|
+
store_status worker.jid, :complete, expiry
|
39
56
|
rescue Worker::Stopped
|
40
|
-
store_status worker.jid, :stopped,
|
57
|
+
store_status worker.jid, :stopped, expiry
|
41
58
|
rescue SystemExit, Interrupt
|
42
|
-
store_status worker.jid, :interrupted,
|
59
|
+
store_status worker.jid, :interrupted, expiry
|
43
60
|
raise
|
44
|
-
rescue
|
45
|
-
|
61
|
+
rescue Exception
|
62
|
+
status = :failed
|
63
|
+
if msg['retry']
|
64
|
+
retry_count = msg['retry_count'] || 0
|
65
|
+
if retry_count < retry_attempts_from(msg['retry'], DEFAULT_MAX_RETRY_ATTEMPTS)
|
66
|
+
status = :retrying
|
67
|
+
end
|
68
|
+
end
|
69
|
+
store_status worker.jid, status, expiry
|
46
70
|
raise
|
47
71
|
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def retry_attempts_from(msg_retry, default)
|
76
|
+
msg_retry.is_a?(Integer) ? msg_retry : default
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Helper method to easily configure sidekiq-status server middleware
|
81
|
+
# whatever the Sidekiq version is.
|
82
|
+
# @param [Sidekiq] sidekiq_config the Sidekiq config
|
83
|
+
# @param [Hash] server_middleware_options server middleware initialization options
|
84
|
+
# @option server_middleware_options [Fixnum] :expiration ttl for complete jobs
|
85
|
+
def self.configure_server_middleware(sidekiq_config, server_middleware_options = {})
|
86
|
+
sidekiq_config.server_middleware do |chain|
|
87
|
+
if Sidekiq.major_version < 5
|
88
|
+
chain.insert_after Sidekiq::Middleware::Server::Logging,
|
89
|
+
Sidekiq::Status::ServerMiddleware, server_middleware_options
|
90
|
+
else
|
91
|
+
chain.add Sidekiq::Status::ServerMiddleware, server_middleware_options
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
48
95
|
end
|
49
96
|
end
|
data/lib/sidekiq-status/web.rb
CHANGED
@@ -6,9 +6,39 @@ module Sidekiq::Status
|
|
6
6
|
# Location of Sidekiq::Status::Web view templates
|
7
7
|
VIEW_PATH = File.expand_path('../../../web/views', __FILE__)
|
8
8
|
|
9
|
+
DEFAULT_PER_PAGE_OPTS = [25, 50, 100].freeze
|
10
|
+
DEFAULT_PER_PAGE = 25
|
11
|
+
|
12
|
+
class << self
|
13
|
+
def per_page_opts= arr
|
14
|
+
@per_page_opts = arr
|
15
|
+
end
|
16
|
+
def per_page_opts
|
17
|
+
@per_page_opts || DEFAULT_PER_PAGE_OPTS
|
18
|
+
end
|
19
|
+
def default_per_page= val
|
20
|
+
@default_per_page = val
|
21
|
+
end
|
22
|
+
def default_per_page
|
23
|
+
@default_per_page || DEFAULT_PER_PAGE
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
9
27
|
# @param [Sidekiq::Web] app
|
10
28
|
def self.registered(app)
|
29
|
+
|
30
|
+
# Allow method overrides to support RESTful deletes
|
31
|
+
app.set :method_override, true
|
32
|
+
|
11
33
|
app.helpers do
|
34
|
+
def csrf_tag
|
35
|
+
"<input type='hidden' name='authenticity_token' value='#{session[:csrf]}'/>"
|
36
|
+
end
|
37
|
+
|
38
|
+
def poll_path
|
39
|
+
"?#{request.query_string}" if params[:poll]
|
40
|
+
end
|
41
|
+
|
12
42
|
def sidekiq_status_template(name)
|
13
43
|
path = File.join(VIEW_PATH, name.to_s) + ".erb"
|
14
44
|
File.open(path).read
|
@@ -16,7 +46,7 @@ module Sidekiq::Status
|
|
16
46
|
|
17
47
|
def add_details_to_status(status)
|
18
48
|
status['label'] = status_label(status['status'])
|
19
|
-
status["pct_complete"]
|
49
|
+
status["pct_complete"] ||= pct_complete(status)
|
20
50
|
return status
|
21
51
|
end
|
22
52
|
|
@@ -29,7 +59,7 @@ module Sidekiq::Status
|
|
29
59
|
case status
|
30
60
|
when 'complete'
|
31
61
|
'success'
|
32
|
-
when 'working'
|
62
|
+
when 'working', 'retrying'
|
33
63
|
'warning'
|
34
64
|
when 'queued'
|
35
65
|
'primary'
|
@@ -39,53 +69,50 @@ module Sidekiq::Status
|
|
39
69
|
end
|
40
70
|
|
41
71
|
def has_sort_by?(value)
|
42
|
-
["worker", "status", "update_time", "pct_complete", "message"].include?(value)
|
72
|
+
["worker", "status", "update_time", "pct_complete", "message", "args"].include?(value)
|
43
73
|
end
|
44
74
|
end
|
45
75
|
|
46
76
|
app.get '/statuses' do
|
77
|
+
|
47
78
|
namespace_jids = Sidekiq.redis{ |conn| conn.keys('sidekiq:status:*') }
|
48
|
-
jids = namespace_jids.map{|id_namespace| id_namespace.split(':').last }
|
79
|
+
jids = namespace_jids.map{ |id_namespace| id_namespace.split(':').last }
|
49
80
|
@statuses = []
|
50
81
|
|
51
82
|
jids.each do |jid|
|
52
83
|
status = Sidekiq::Status::get_all jid
|
53
84
|
next if !status || status.count < 2
|
54
85
|
status = add_details_to_status(status)
|
55
|
-
@statuses <<
|
86
|
+
@statuses << status
|
56
87
|
end
|
57
88
|
|
58
89
|
sort_by = has_sort_by?(params[:sort_by]) ? params[:sort_by] : "update_time"
|
59
90
|
sort_dir = "asc"
|
60
91
|
|
61
92
|
if params[:sort_dir] == "asc"
|
62
|
-
@statuses = @statuses.sort { |x,y| x
|
93
|
+
@statuses = @statuses.sort { |x,y| (x[sort_by] <=> y[sort_by]) || -1 }
|
63
94
|
else
|
64
95
|
sort_dir = "desc"
|
65
|
-
@statuses = @statuses.sort { |y,x| x
|
66
|
-
end
|
67
|
-
|
68
|
-
working_jobs = @statuses.select{|job| job.status == "working"}
|
69
|
-
size = params[:size] ? params[:size].to_i : 25
|
70
|
-
if working_jobs.size >= size
|
71
|
-
@statuses = working_jobs
|
72
|
-
else
|
73
|
-
@statuses = (@statuses.size >= size) ? @statuses.take(size) : @statuses
|
96
|
+
@statuses = @statuses.sort { |y,x| (x[sort_by] <=> y[sort_by]) || 1 }
|
74
97
|
end
|
75
98
|
|
99
|
+
# Sidekiq pagination
|
100
|
+
@total_size = @statuses.count
|
101
|
+
@count = params[:per_page] ? params[:per_page].to_i : Sidekiq::Status::Web.default_per_page
|
102
|
+
@count = @total_size if params[:per_page] == 'all'
|
103
|
+
@current_page = params[:page].to_i < 1 ? 1 : params[:page].to_i
|
104
|
+
@statuses = @statuses.slice((@current_page - 1) * @count, @count)
|
76
105
|
|
77
106
|
@headers = [
|
78
|
-
{
|
79
|
-
{
|
80
|
-
{
|
81
|
-
{
|
82
|
-
{
|
107
|
+
{id: "worker", name: "Worker / JID", class: nil, url: nil},
|
108
|
+
{id: "args", name: "Arguments", class: nil, url: nil},
|
109
|
+
{id: "status", name: "Status", class: nil, url: nil},
|
110
|
+
{id: "update_time", name: "Last Updated", class: nil, url: nil},
|
111
|
+
{id: "pct_complete", name: "Progress", class: nil, url: nil},
|
83
112
|
]
|
84
113
|
|
85
114
|
@headers.each do |h|
|
86
|
-
|
87
|
-
params["sort_dir"] = (sort_by == h[:id] && sort_dir == "asc") ? "desc" : "asc"
|
88
|
-
h[:url] = "statuses?" + params.map {|k,v| "#{k}=#{v}" }.join("&")
|
115
|
+
h[:url] = "statuses?" + params.merge("sort_by" => h[:id], "sort_dir" => (sort_by == h[:id] && sort_dir == "asc") ? "desc" : "asc").map{|k, v| "#{k}=#{CGI.escape v.to_s}"}.join("&")
|
89
116
|
h[:class] = "sorted_#{sort_dir}" if sort_by == h[:id]
|
90
117
|
end
|
91
118
|
|
@@ -98,16 +125,33 @@ module Sidekiq::Status
|
|
98
125
|
if job.empty?
|
99
126
|
halt [404, {"Content-Type" => "text/html"}, [erb(sidekiq_status_template(:status_not_found))]]
|
100
127
|
else
|
101
|
-
@status =
|
128
|
+
@status = add_details_to_status(job)
|
102
129
|
erb(sidekiq_status_template(:status))
|
103
130
|
end
|
104
131
|
end
|
132
|
+
|
133
|
+
# Retries a failed job from the status list
|
134
|
+
app.put '/statuses' do
|
135
|
+
job = Sidekiq::RetrySet.new.find_job(params[:jid])
|
136
|
+
job ||= Sidekiq::DeadSet.new.find_job(params[:jid])
|
137
|
+
job.retry if job
|
138
|
+
halt [302, { "Location" => request.referer }, []]
|
139
|
+
end
|
140
|
+
|
141
|
+
# Removes a completed job from the status list
|
142
|
+
app.delete '/statuses' do
|
143
|
+
Sidekiq::Status.delete(params[:jid])
|
144
|
+
halt [302, { "Location" => request.referer }, []]
|
145
|
+
end
|
105
146
|
end
|
106
147
|
end
|
107
148
|
end
|
108
149
|
|
109
150
|
require 'sidekiq/web' unless defined?(Sidekiq::Web)
|
110
151
|
Sidekiq::Web.register(Sidekiq::Status::Web)
|
152
|
+
["per_page", "sort_by", "sort_dir"].each do |key|
|
153
|
+
Sidekiq::WebHelpers::SAFE_QPARAMS.push(key)
|
154
|
+
end
|
111
155
|
if Sidekiq::Web.tabs.is_a?(Array)
|
112
156
|
# For sidekiq < 2.5
|
113
157
|
Sidekiq::Web.tabs << "statuses"
|
@@ -27,14 +27,16 @@ module Sidekiq::Status::Worker
|
|
27
27
|
# @param String optional message
|
28
28
|
# @return [String]
|
29
29
|
def at(num, message = nil)
|
30
|
-
|
31
|
-
|
30
|
+
@_status_total = 100 if @_status_total.nil?
|
31
|
+
pct_complete = ((num / @_status_total.to_f) * 100).to_i rescue 0
|
32
|
+
store(at: num, total: @_status_total, pct_complete: pct_complete, message: message)
|
32
33
|
end
|
33
34
|
|
34
35
|
# Sets total number of tasks
|
35
36
|
# @param Fixnum total number of tasks
|
36
37
|
# @return [String]
|
37
38
|
def total(num)
|
39
|
+
@_status_total = num
|
38
40
|
store(total: num)
|
39
41
|
end
|
40
42
|
|
data/sidekiq-status.gemspec
CHANGED
@@ -2,8 +2,8 @@
|
|
2
2
|
require File.expand_path('../lib/sidekiq-status/version', __FILE__)
|
3
3
|
|
4
4
|
Gem::Specification.new do |gem|
|
5
|
-
gem.authors = ['Evgeniy Tsvigun']
|
6
|
-
gem.email = ['utgarda@gmail.com']
|
5
|
+
gem.authors = ['Evgeniy Tsvigun', 'Kenaniah Cerny']
|
6
|
+
gem.email = ['utgarda@gmail.com', 'kenaniah@gmail.com']
|
7
7
|
gem.summary = 'An extension to the sidekiq message processing to track your jobs'
|
8
8
|
gem.homepage = 'http://github.com/utgarda/sidekiq-status'
|
9
9
|
gem.license = 'MIT'
|
@@ -14,8 +14,10 @@ Gem::Specification.new do |gem|
|
|
14
14
|
gem.require_paths = ['lib']
|
15
15
|
gem.version = Sidekiq::Status::VERSION
|
16
16
|
|
17
|
-
gem.add_dependency 'sidekiq', '>=
|
17
|
+
gem.add_dependency 'sidekiq', '>= 3.0'
|
18
18
|
gem.add_dependency 'chronic_duration'
|
19
|
+
gem.add_development_dependency 'appraisal'
|
20
|
+
gem.add_development_dependency 'colorize'
|
19
21
|
gem.add_development_dependency 'rack-test'
|
20
22
|
gem.add_development_dependency 'rake'
|
21
23
|
gem.add_development_dependency 'rspec'
|
@@ -5,18 +5,20 @@ describe Sidekiq::Status::ClientMiddleware do
|
|
5
5
|
let!(:redis) { Sidekiq.redis { |conn| conn } }
|
6
6
|
let!(:job_id) { SecureRandom.hex(12) }
|
7
7
|
|
8
|
-
|
9
|
-
|
8
|
+
before do
|
9
|
+
allow(SecureRandom).to receive(:hex).once.and_return(job_id)
|
10
|
+
end
|
11
|
+
|
12
|
+
describe "without :expiration parameter" do
|
13
|
+
|
10
14
|
it "sets queued status" do
|
11
|
-
|
12
|
-
expect(StubJob.perform_async(:arg1 => 'val1')).to eq(job_id)
|
15
|
+
expect(StubJob.perform_async arg1: 'val1').to eq(job_id)
|
13
16
|
expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('queued')
|
14
17
|
expect(Sidekiq::Status::queued?(job_id)).to be_truthy
|
15
18
|
end
|
16
19
|
|
17
20
|
it "sets status hash ttl" do
|
18
|
-
|
19
|
-
expect(StubJob.perform_async(:arg1 => 'val1')).to eq(job_id)
|
21
|
+
expect(StubJob.perform_async arg1: 'val1').to eq(job_id)
|
20
22
|
expect(1..Sidekiq::Status::DEFAULT_EXPIRY).to cover redis.ttl("sidekiq:status:#{job_id}")
|
21
23
|
end
|
22
24
|
|
@@ -35,18 +37,22 @@ describe Sidekiq::Status::ClientMiddleware do
|
|
35
37
|
Sidekiq::Status::ClientMiddleware.new.call(StubJob, {'jid' => SecureRandom.hex}, :queued) do end
|
36
38
|
end
|
37
39
|
end
|
40
|
+
|
38
41
|
end
|
39
42
|
|
40
|
-
describe ":expiration parameter" do
|
43
|
+
describe "with :expiration parameter" do
|
44
|
+
|
41
45
|
let(:huge_expiration) { Sidekiq::Status::DEFAULT_EXPIRY * 100 }
|
46
|
+
|
47
|
+
# Ensure client middleware is loaded with an expiration parameter set
|
42
48
|
before do
|
43
|
-
|
49
|
+
client_middleware expiration: huge_expiration
|
44
50
|
end
|
45
51
|
|
46
52
|
it "overwrites default expiry value" do
|
47
|
-
|
48
|
-
StubJob.perform_async(:arg1 => 'val1')
|
53
|
+
StubJob.perform_async arg1: 'val1'
|
49
54
|
expect((Sidekiq::Status::DEFAULT_EXPIRY+1)..huge_expiration).to cover redis.ttl("sidekiq:status:#{job_id}")
|
50
55
|
end
|
56
|
+
|
51
57
|
end
|
52
58
|
end
|
@@ -5,15 +5,18 @@ describe Sidekiq::Status::ServerMiddleware do
|
|
5
5
|
let!(:redis) { Sidekiq.redis { |conn| conn } }
|
6
6
|
let!(:job_id) { SecureRandom.hex(12) }
|
7
7
|
|
8
|
-
describe "
|
8
|
+
describe "without :expiration parameter" do
|
9
9
|
it "sets working/complete status" do
|
10
|
-
thread = confirmations_thread 4, "status_updates", "job_messages_#{job_id}"
|
11
10
|
allow(SecureRandom).to receive(:hex).once.and_return(job_id)
|
12
11
|
start_server do
|
13
|
-
|
14
|
-
expect(
|
15
|
-
|
16
|
-
|
12
|
+
thread = redis_thread 4, "status_updates", "job_messages_#{job_id}"
|
13
|
+
expect(ConfirmationJob.perform_async arg1: 'val1').to eq(job_id)
|
14
|
+
expect(thread.value).to eq([
|
15
|
+
job_id,
|
16
|
+
job_id,
|
17
|
+
"while in #perform, status = working",
|
18
|
+
job_id
|
19
|
+
])
|
17
20
|
end
|
18
21
|
expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('complete')
|
19
22
|
expect(Sidekiq::Status::complete?(job_id)).to be_truthy
|
@@ -30,6 +33,17 @@ describe Sidekiq::Status::ServerMiddleware do
|
|
30
33
|
expect(Sidekiq::Status::failed?(job_id)).to be_truthy
|
31
34
|
end
|
32
35
|
|
36
|
+
it "sets failed status when Exception raised" do
|
37
|
+
allow(SecureRandom).to receive(:hex).once.and_return(job_id)
|
38
|
+
start_server do
|
39
|
+
expect(capture_status_updates(3) {
|
40
|
+
expect(FailingHardJob.perform_async).to eq(job_id)
|
41
|
+
}).to eq([job_id]*3)
|
42
|
+
end
|
43
|
+
expect(redis.hget("sidekiq:status:#{job_id}", :status)).to eq('failed')
|
44
|
+
expect(Sidekiq::Status::failed?(job_id)).to be_truthy
|
45
|
+
end
|
46
|
+
|
33
47
|
context "sets interrupted status" do
|
34
48
|
it "on system exit signal" do
|
35
49
|
allow(SecureRandom).to receive(:hex).once.and_return(job_id)
|
@@ -58,13 +72,13 @@ describe Sidekiq::Status::ServerMiddleware do
|
|
58
72
|
it "sets status hash ttl" do
|
59
73
|
allow(SecureRandom).to receive(:hex).once.and_return(job_id)
|
60
74
|
start_server do
|
61
|
-
expect(StubJob.perform_async
|
75
|
+
expect(StubJob.perform_async arg1: 'val1').to eq(job_id)
|
62
76
|
end
|
63
77
|
expect(1..Sidekiq::Status::DEFAULT_EXPIRY).to cover redis.ttl("sidekiq:status:#{job_id}")
|
64
78
|
end
|
65
79
|
end
|
66
80
|
|
67
|
-
describe ":expiration parameter" do
|
81
|
+
describe "with :expiration parameter" do
|
68
82
|
let(:huge_expiration) { Sidekiq::Status::DEFAULT_EXPIRY * 100 }
|
69
83
|
before do
|
70
84
|
allow(SecureRandom).to receive(:hex).once.and_return(job_id)
|
@@ -72,7 +86,7 @@ describe Sidekiq::Status::ServerMiddleware do
|
|
72
86
|
|
73
87
|
it "overwrites default expiry value" do
|
74
88
|
start_server(:expiration => huge_expiration) do
|
75
|
-
StubJob.perform_async
|
89
|
+
StubJob.perform_async arg1: 'val1'
|
76
90
|
end
|
77
91
|
expect((Sidekiq::Status::DEFAULT_EXPIRY-1)..huge_expiration).to cover redis.ttl("sidekiq:status:#{job_id}")
|
78
92
|
end
|
@@ -81,7 +95,7 @@ describe Sidekiq::Status::ServerMiddleware do
|
|
81
95
|
overwritten_expiration = huge_expiration * 100
|
82
96
|
allow_any_instance_of(StubJob).to receive(:expiration).and_return(overwritten_expiration)
|
83
97
|
start_server(:expiration => huge_expiration) do
|
84
|
-
StubJob.perform_async
|
98
|
+
StubJob.perform_async arg1: 'val1'
|
85
99
|
end
|
86
100
|
expect((huge_expiration+1)..overwritten_expiration).to cover redis.ttl("sidekiq:status:#{job_id}")
|
87
101
|
end
|