queue_classic 3.2.0.RC1 → 4.0.0.pre.alpha1

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.
Files changed (41) hide show
  1. checksums.yaml +5 -5
  2. data/.circleci/config.yml +151 -0
  3. data/.gitignore +2 -0
  4. data/{changelog → CHANGELOG.md} +80 -34
  5. data/CODE_OF_CONDUCT.md +46 -0
  6. data/Gemfile +8 -5
  7. data/README.md +77 -85
  8. data/Rakefile +2 -0
  9. data/lib/generators/queue_classic/install_generator.rb +6 -0
  10. data/lib/generators/queue_classic/templates/add_queue_classic.rb +3 -1
  11. data/lib/generators/queue_classic/templates/update_queue_classic_3_0_0.rb +3 -1
  12. data/lib/generators/queue_classic/templates/update_queue_classic_3_0_2.rb +3 -1
  13. data/lib/generators/queue_classic/templates/update_queue_classic_3_1_0.rb +3 -1
  14. data/lib/generators/queue_classic/templates/update_queue_classic_4_0_0.rb +11 -0
  15. data/lib/queue_classic.rb +4 -11
  16. data/lib/queue_classic/config.rb +2 -1
  17. data/lib/queue_classic/conn_adapter.rb +28 -14
  18. data/lib/queue_classic/queue.rb +65 -11
  19. data/lib/queue_classic/railtie.rb +2 -0
  20. data/lib/queue_classic/setup.rb +24 -7
  21. data/lib/queue_classic/tasks.rb +4 -5
  22. data/lib/queue_classic/version.rb +3 -1
  23. data/lib/queue_classic/worker.rb +10 -5
  24. data/queue_classic.gemspec +1 -1
  25. data/sql/create_table.sql +7 -16
  26. data/sql/ddl.sql +6 -82
  27. data/sql/downgrade_from_4_0_0.sql +88 -0
  28. data/sql/update_to_3_0_0.sql +5 -5
  29. data/sql/update_to_3_1_0.sql +6 -6
  30. data/sql/update_to_4_0_0.sql +6 -0
  31. data/test/benchmark_test.rb +2 -0
  32. data/test/config_test.rb +2 -0
  33. data/test/helper.rb +34 -0
  34. data/test/lib/queue_classic_rails_connection_test.rb +9 -6
  35. data/test/lib/queue_classic_test.rb +2 -0
  36. data/test/queue_test.rb +62 -2
  37. data/test/rails-tests/.gitignore +2 -0
  38. data/test/rails-tests/rails523.sh +23 -0
  39. data/test/worker_test.rb +138 -17
  40. metadata +15 -7
  41. data/.travis.yml +0 -15
@@ -0,0 +1,88 @@
1
+ DO $$DECLARE r record;
2
+ BEGIN
3
+ -- If jsonb type is available, do nothing as we're downgrading from 4.0.0
4
+ IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'jsonb') THEN
5
+ -- do nothing - it should already be already jsonb
6
+ -- Otherwise, use json type for the args column if available
7
+ ELSIF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'json') THEN
8
+ -- this should only happen if someone downgrades QC and their database < pg 9.4
9
+ ALTER TABLE queue_classic_jobs ALTER COLUMN args TYPE json USING args::json;
10
+ END IF;
11
+
12
+
13
+ END$$;
14
+
15
+
16
+ --
17
+ -- Re install the lock_head function
18
+ --
19
+
20
+ -- We are declaring the return type to be queue_classic_jobs.
21
+ -- This is ok since I am assuming that all of the users added queues will
22
+ -- have identical columns to queue_classic_jobs.
23
+ -- When QC supports queues with columns other than the default, we will have to change this.
24
+
25
+ CREATE OR REPLACE FUNCTION lock_head(q_name varchar, top_boundary integer)
26
+ RETURNS SETOF queue_classic_jobs AS $$
27
+ DECLARE
28
+ unlocked bigint;
29
+ relative_top integer;
30
+ job_count integer;
31
+ BEGIN
32
+ -- The purpose is to release contention for the first spot in the table.
33
+ -- The select count(*) is going to slow down dequeue performance but allow
34
+ -- for more workers. Would love to see some optimization here...
35
+
36
+ EXECUTE 'SELECT count(*) FROM '
37
+ || '(SELECT * FROM queue_classic_jobs '
38
+ || ' WHERE locked_at IS NULL'
39
+ || ' AND q_name = '
40
+ || quote_literal(q_name)
41
+ || ' AND scheduled_at <= '
42
+ || quote_literal(now())
43
+ || ' LIMIT '
44
+ || quote_literal(top_boundary)
45
+ || ') limited'
46
+ INTO job_count;
47
+
48
+ SELECT TRUNC(random() * (top_boundary - 1))
49
+ INTO relative_top;
50
+
51
+ IF job_count < top_boundary THEN
52
+ relative_top = 0;
53
+ END IF;
54
+
55
+ LOOP
56
+ BEGIN
57
+ EXECUTE 'SELECT id FROM queue_classic_jobs '
58
+ || ' WHERE locked_at IS NULL'
59
+ || ' AND q_name = '
60
+ || quote_literal(q_name)
61
+ || ' AND scheduled_at <= '
62
+ || quote_literal(now())
63
+ || ' ORDER BY id ASC'
64
+ || ' LIMIT 1'
65
+ || ' OFFSET ' || quote_literal(relative_top)
66
+ || ' FOR UPDATE NOWAIT'
67
+ INTO unlocked;
68
+ EXIT;
69
+ EXCEPTION
70
+ WHEN lock_not_available THEN
71
+ -- do nothing. loop again and hope we get a lock
72
+ END;
73
+ END LOOP;
74
+
75
+ RETURN QUERY EXECUTE 'UPDATE queue_classic_jobs '
76
+ || ' SET locked_at = (CURRENT_TIMESTAMP),'
77
+ || ' locked_by = (select pg_backend_pid())'
78
+ || ' WHERE id = $1'
79
+ || ' AND locked_at is NULL'
80
+ || ' RETURNING *'
81
+ USING unlocked;
82
+
83
+ RETURN;
84
+ END $$ LANGUAGE plpgsql;
85
+
86
+ CREATE OR REPLACE FUNCTION lock_head(tname varchar) RETURNS SETOF queue_classic_jobs AS $$ BEGIN
87
+ RETURN QUERY EXECUTE 'SELECT * FROM lock_head($1,10)' USING tname;
88
+ END $$ LANGUAGE plpgsql;
@@ -1,10 +1,10 @@
1
1
  DO $$DECLARE r record;
2
2
  BEGIN
3
- BEGIN
4
- ALTER TABLE queue_classic_jobs ADD COLUMN created_at timestamptz default now();
5
- EXCEPTION
6
- WHEN duplicate_column THEN RAISE NOTICE 'column created_at already exists in queue_classic_jobs.';
7
- END;
3
+ BEGIN
4
+ ALTER TABLE queue_classic_jobs ADD COLUMN created_at timestamptz DEFAULT now();
5
+ EXCEPTION
6
+ WHEN duplicate_column THEN RAISE NOTICE 'column created_at already exists in queue_classic_jobs.';
7
+ END;
8
8
  END$$;
9
9
 
10
10
  DO $$DECLARE r record;
@@ -1,9 +1,9 @@
1
1
  DO $$DECLARE r record;
2
2
  BEGIN
3
- BEGIN
4
- ALTER TABLE queue_classic_jobs ADD COLUMN scheduled_at timestamptz default now();
5
- CREATE INDEX idx_qc_on_scheduled_at_only_unlocked ON queue_classic_jobs (scheduled_at, id) WHERE locked_at IS NULL;
6
- EXCEPTION
7
- WHEN duplicate_column THEN RAISE NOTICE 'column scheduled_at already exists in queue_classic_jobs.';
8
- END;
3
+ BEGIN
4
+ ALTER TABLE queue_classic_jobs ADD COLUMN scheduled_at timestamptz DEFAULT now();
5
+ CREATE INDEX idx_qc_on_scheduled_at_only_unlocked ON queue_classic_jobs (scheduled_at, id) WHERE locked_at IS NULL;
6
+ EXCEPTION
7
+ WHEN duplicate_column THEN RAISE NOTICE 'column scheduled_at already exists in queue_classic_jobs.';
8
+ END;
9
9
  END$$;
@@ -0,0 +1,6 @@
1
+ DO $$DECLARE r record;
2
+ BEGIN
3
+ ALTER TABLE queue_classic_jobs ALTER COLUMN args TYPE jsonb USING args::jsonb;
4
+ DROP FUNCTION IF EXISTS lock_head(tname varchar);
5
+ DROP FUNCTION IF EXISTS lock_head(q_name varchar, top_boundary integer);
6
+ END$$;
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'helper'
2
4
 
3
5
  if ENV["QC_BENCHMARK"]
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'helper'
2
4
 
3
5
  class ConfigTest < QCTest
@@ -1,6 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bundler"
4
+ require "minitest/reporters"
5
+
2
6
  Bundler.setup :default, :test
3
7
 
8
+ if ENV['CIRCLECI'] == "true"
9
+ Minitest::Reporters.use! Minitest::Reporters::JUnitReporter.new
10
+ else
11
+ Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new
12
+ end
13
+
4
14
  ENV["DATABASE_URL"] ||= "postgres:///queue_classic_test"
5
15
 
6
16
  require_relative '../lib/queue_classic'
@@ -58,4 +68,28 @@ class QCTest < Minitest::Test
58
68
  ensure
59
69
  original_environment.each { |name, value| ENV[name] = value }
60
70
  end
71
+
72
+ def stub_any_instance(class_name, method_name, definition)
73
+ new_method_name = "new_#{method_name}"
74
+ original_method_name = "original_#{method_name}"
75
+
76
+ method_present = class_name.instance_methods(false).include? method_name
77
+
78
+ if method_present
79
+ class_name.send(:alias_method, original_method_name, method_name)
80
+ class_name.send(:define_method, new_method_name, definition)
81
+ class_name.send(:alias_method, method_name, new_method_name)
82
+
83
+ yield
84
+ else
85
+ message = "#{class_name} does not have method #{method_name}."
86
+ message << "\nAvailable methods: #{class_name.instance_methods(false)}"
87
+ raise ArgumentError.new message
88
+ end
89
+ ensure
90
+ if method_present
91
+ class_name.send(:alias_method, method_name, original_method_name)
92
+ class_name.send(:undef_method, new_method_name)
93
+ end
94
+ end
61
95
  end
@@ -1,15 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require File.expand_path("../../helper.rb", __FILE__)
2
4
 
3
5
  class QueueClassicRailsConnectionTest < QCTest
4
6
  def before_setup
5
- Object.send :const_set, :ActiveRecord, Module.new
6
- ActiveRecord.const_set :Base, Module.new
7
-
8
7
  @original_conn_adapter = QC.default_conn_adapter
9
8
  QC.default_conn_adapter = nil
10
9
  end
11
10
 
12
- def after_teardown
11
+ def before_teardown
13
12
  ActiveRecord.send :remove_const, :Base
14
13
  Object.send :remove_const, :ActiveRecord
15
14
 
@@ -18,12 +17,14 @@ class QueueClassicRailsConnectionTest < QCTest
18
17
 
19
18
  def test_uses_active_record_connection_if_exists
20
19
  connection = get_connection
21
- assert connection.verify
20
+ QC.default_conn_adapter.execute('SELECT 1;')
21
+ connection.verify
22
22
  end
23
23
 
24
24
  def test_does_not_use_active_record_connection_if_env_var_set
25
25
  with_env 'QC_RAILS_DATABASE' => 'false' do
26
26
  connection = get_connection
27
+ QC.default_conn_adapter.execute('SELECT 1;')
27
28
  assert_raises(MockExpectationError) { connection.verify }
28
29
  end
29
30
  end
@@ -31,8 +32,10 @@ class QueueClassicRailsConnectionTest < QCTest
31
32
  private
32
33
  def get_connection
33
34
  connection = Minitest::Mock.new
34
- connection.expect(:raw_connection, QC::ConnAdapter.new.connection)
35
+ connection.expect(:raw_connection, QC::ConnAdapter.new(active_record_connection_share: true).connection)
35
36
 
37
+ Object.send :const_set, :ActiveRecord, Module.new
38
+ ActiveRecord.const_set :Base, Module.new
36
39
  ActiveRecord::Base.define_singleton_method(:connection) do
37
40
  connection
38
41
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require File.expand_path("../../helper.rb", __FILE__)
2
4
 
3
5
  class QueueClassicTest < QCTest
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'helper'
2
4
 
3
5
  class QueueTest < QCTest
4
6
 
5
- ResetError = Class.new(PGError)
7
+ ResetError = Class.new(PG::Error)
6
8
 
7
9
  def test_enqueue
8
10
  QC.enqueue("Klass.method")
@@ -62,6 +64,20 @@ class QueueTest < QCTest
62
64
  def test_count
63
65
  QC.enqueue("Klass.method")
64
66
  assert_equal(1, QC.count)
67
+
68
+ QC.enqueue("Klass.method")
69
+ assert_equal(2, QC.count)
70
+ assert_equal(2, QC.count_ready)
71
+ assert_equal(0, QC.count_scheduled)
72
+
73
+ QC.enqueue_in(60, "Klass.method")
74
+ assert_equal(3, QC.count)
75
+ assert_equal(2, QC.count_ready)
76
+ assert_equal(1, QC.count_scheduled)
77
+
78
+ assert_raises(ArgumentError) do
79
+ QC.count(:potatoes)
80
+ end
65
81
  end
66
82
 
67
83
  def test_delete
@@ -105,13 +121,57 @@ class QueueTest < QCTest
105
121
  queue.enqueue("Klass.method")
106
122
  assert_equal(1, queue.count)
107
123
  conn = queue.conn_adapter.connection
108
- def conn.exec(*args); raise(PGError); end
124
+ def conn.exec(*args); raise(PG::Error); end
109
125
  def conn.reset(*args); raise(ResetError) end
110
126
  # We ensure that the reset method is called on the connection.
111
127
  assert_raises(PG::Error, ResetError) {queue.enqueue("Klass.other_method")}
112
128
  queue.conn_adapter.disconnect
113
129
  end
114
130
 
131
+ def test_enqueue_retry
132
+ queue = QC::Queue.new("queue_classic_jobs")
133
+ queue.conn_adapter = QC::ConnAdapter.new
134
+ conn = queue.conn_adapter.connection
135
+ conn.exec('select pg_terminate_backend(pg_backend_pid())') rescue nil
136
+ queue.enqueue("Klass.method")
137
+ assert_equal(1, queue.count)
138
+ queue.conn_adapter.disconnect
139
+ end
140
+
141
+ def test_enqueue_stops_retrying_on_permanent_error
142
+ queue = QC::Queue.new("queue_classic_jobs")
143
+ queue.conn_adapter = QC::ConnAdapter.new
144
+ conn = queue.conn_adapter.connection
145
+ conn.exec('select pg_terminate_backend(pg_backend_pid())') rescue nil
146
+ # Simulate permanent connection error
147
+ def conn.exec(*args); raise(PG::Error); end
148
+ # Ensure that the error is reraised on second time
149
+ assert_raises(PG::Error) {queue.enqueue("Klass.other_method")}
150
+ queue.conn_adapter.disconnect
151
+ end
152
+
153
+ def test_enqueue_in_retry
154
+ queue = QC::Queue.new("queue_classic_jobs")
155
+ queue.conn_adapter = QC::ConnAdapter.new
156
+ conn = queue.conn_adapter.connection
157
+ conn.exec('select pg_terminate_backend(pg_backend_pid())') rescue nil
158
+ queue.enqueue_in(10,"Klass.method")
159
+ assert_equal(1, queue.count)
160
+ queue.conn_adapter.disconnect
161
+ end
162
+
163
+ def test_enqueue_in_stops_retrying_on_permanent_error
164
+ queue = QC::Queue.new("queue_classic_jobs")
165
+ queue.conn_adapter = QC::ConnAdapter.new
166
+ conn = queue.conn_adapter.connection
167
+ conn.exec('select pg_terminate_backend(pg_backend_pid())') rescue nil
168
+ # Simulate permanent connection error
169
+ def conn.exec(*args); raise(PG::Error); end
170
+ # Ensure that the error is reraised on second time
171
+ assert_raises(PG::Error) {queue.enqueue_in(10,"Klass.method")}
172
+ queue.conn_adapter.disconnect
173
+ end
174
+
115
175
  def test_custom_default_queue
116
176
  queue_class = Class.new do
117
177
  attr_accessor :jobs
@@ -0,0 +1,2 @@
1
+ vendor/
2
+ qctest*/
@@ -0,0 +1,23 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # remove any old folder, should only matter locally
5
+ rm -rf qctest523
6
+
7
+ # install rails but not with much stuff
8
+ gem install rails -v 5.2.3
9
+ rails new qctest523 --api --database=postgresql --skip-test-unit --skip-keeps --skip-spring --skip-sprockets --skip-javascript --skip-turbolinks
10
+ cd qctest523
11
+
12
+ # get the db setup, run any default migrations
13
+ bundle install
14
+ bundle exec rails db:drop:all
15
+ bundle exec rails db:create
16
+ bundle exec rails db:migrate
17
+ bundle exec rails db:setup
18
+
19
+ # install qc --> gem file, bundle, add ourselves and migrate.
20
+ echo "gem 'queue_classic', path: '../../../'" >> Gemfile
21
+ bundle install
22
+ bundle exec rails generate queue_classic:install
23
+ bundle exec rails db:migrate
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'helper'
2
4
 
3
5
  module TestObject
@@ -121,16 +123,16 @@ class WorkerTest < QCTest
121
123
  t.join
122
124
  end
123
125
 
124
- def test_worker_uses_one_conn
125
- skip "This test is broken and needs to be fixed."
126
-
126
+ def test_worker_reuses_conn
127
127
  QC.enqueue("TestObject.no_args")
128
+ count = QC.default_conn_adapter.execute("SELECT count(*) from pg_stat_activity where datname = current_database()")["count"].to_i;
128
129
  worker = TestWorker.new
129
130
  worker.work
130
- assert_equal(
131
- 1,
132
- QC.default_conn_adapter.execute("SELECT count(*) from pg_stat_activity where datname = current_database()")["count"].to_i,
133
- "Multiple connections found -- are there open connections to #{ QC.default_conn_adapter.send(:db_url) } in other terminals?"
131
+
132
+ new_count = QC.default_conn_adapter.execute("SELECT count(*) from pg_stat_activity where datname = current_database()")["count"].to_i;
133
+ assert(
134
+ new_count == count,
135
+ "Worker should not initialize new connections to #{ QC.default_conn_adapter.send(:db_url) }."
134
136
  )
135
137
  end
136
138
 
@@ -176,15 +178,6 @@ class WorkerTest < QCTest
176
178
  assert_equal(0, worker.failed_count)
177
179
  end
178
180
 
179
- def test_init_worker_with_arg
180
- with_database 'postgres:///invalid' do
181
- conn = PG::Connection.connect(dbname: 'queue_classic_test')
182
- QC::Worker.new connection: conn
183
-
184
- conn.close
185
- end
186
- end
187
-
188
181
  def test_init_worker_with_database_url
189
182
  with_database ENV['DATABASE_URL'] || ENV['QC_DATABASE_URL'] do
190
183
  worker = QC::Worker.new
@@ -197,7 +190,135 @@ class WorkerTest < QCTest
197
190
 
198
191
  def test_init_worker_without_conn
199
192
  with_database nil do
200
- assert_raises(ArgumentError) { QC::Worker.new }
193
+ assert_raises(ArgumentError) do
194
+ worker = QC::Worker.new
195
+ QC.enqueue("TestObject.no_args")
196
+ worker.lock_job
197
+ end
198
+ end
199
+ end
200
+
201
+ def test_worker_unlocks_job_on_signal_exception
202
+ job_details = QC.enqueue("Kernel.eval", "raise SignalException.new('INT')")
203
+ worker = TestWorker.new
204
+
205
+ unlocked = nil
206
+
207
+ fake_unlock = Proc.new do |job_id|
208
+ if job_id == job_details['id']
209
+ unlocked = true
210
+ end
211
+ original_unlock(job_id)
212
+ end
213
+
214
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
215
+ begin
216
+ worker.work
217
+ rescue SignalException
218
+ ensure
219
+ assert unlocked, "SignalException failed to unlock the job in the queue."
220
+ end
221
+ end
222
+ end
223
+
224
+ def test_worker_unlocks_job_on_system_exit
225
+ job_details = QC.enqueue("Kernel.eval", "raise SystemExit.new")
226
+ worker = TestWorker.new
227
+
228
+ unlocked = nil
229
+
230
+ fake_unlock = Proc.new do |job_id|
231
+ if job_id == job_details['id']
232
+ unlocked = true
233
+ end
234
+ original_unlock(job_id)
235
+ end
236
+
237
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
238
+ begin
239
+ worker.work
240
+ rescue SystemExit
241
+ ensure
242
+ assert unlocked, "SystemExit failed to unlock the job in the queue."
243
+ end
244
+ end
245
+ end
246
+
247
+ def test_worker_does_not_unlock_jobs_on_syntax_error
248
+ job_details = QC.enqueue("Kernel.eval", "bad syntax")
249
+ worker = TestWorker.new
250
+
251
+ unlocked = nil
252
+
253
+ fake_unlock = Proc.new do |job_id|
254
+ if job_id == job_details['id']
255
+ unlocked = true
256
+ end
257
+ original_unlock(job_id)
258
+ end
259
+
260
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
261
+ begin
262
+ errors = capture_stderr_output do
263
+ worker.work
264
+ end
265
+ ensure
266
+ message = ["SyntaxError unexpectedly unlocked the job in the queue."]
267
+ message << "Errors:\n#{errors}" unless errors.empty?
268
+ refute unlocked, message.join("\n")
269
+ end
270
+ end
271
+ end
272
+
273
+ def test_worker_does_not_unlock_jobs_on_load_error
274
+ job_details = QC.enqueue("Kernel.eval", "require 'not_a_real_file'")
275
+ worker = TestWorker.new
276
+
277
+ unlocked = nil
278
+
279
+ fake_unlock = Proc.new do |job_id|
280
+ if job_id == job_details['id']
281
+ unlocked = true
282
+ end
283
+ original_unlock(job_id)
284
+ end
285
+
286
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
287
+ begin
288
+ errors = capture_stderr_output do
289
+ worker.work
290
+ end
291
+ ensure
292
+ message = ["LoadError unexpectedly unlocked the job in the queue."]
293
+ message << "Errors:\n#{errors}" unless errors.empty?
294
+ refute unlocked, message.join("\n")
295
+ end
296
+ end
297
+ end
298
+
299
+ def test_worker_does_not_unlock_jobs_on_no_memory_error
300
+ job_details = QC.enqueue("Kernel.eval", "raise NoMemoryError.new")
301
+ worker = TestWorker.new
302
+
303
+ unlocked = nil
304
+
305
+ fake_unlock = Proc.new do |job_id|
306
+ if job_id == job_details['id']
307
+ unlocked = true
308
+ end
309
+ original_unlock(job_id)
310
+ end
311
+
312
+ stub_any_instance(QC::Queue, :unlock, fake_unlock) do
313
+ begin
314
+ errors = capture_stderr_output do
315
+ worker.work
316
+ end
317
+ ensure
318
+ message = ["NoMemoryError unexpectedly unlocked the job in the queue."]
319
+ message << "Errors:\n#{errors}" unless errors.empty?
320
+ refute unlocked, message.join("\n")
321
+ end
201
322
  end
202
323
  end
203
324