postjob 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +23 -0
  3. data/bin/postjob +11 -0
  4. data/lib/postjob/cli/db.rb +39 -0
  5. data/lib/postjob/cli/job.rb +67 -0
  6. data/lib/postjob/cli/ps.rb +110 -0
  7. data/lib/postjob/cli/run.rb +19 -0
  8. data/lib/postjob/cli.rb +31 -0
  9. data/lib/postjob/error.rb +16 -0
  10. data/lib/postjob/job.rb +66 -0
  11. data/lib/postjob/migrations.rb +97 -0
  12. data/lib/postjob/queue/encoder.rb +40 -0
  13. data/lib/postjob/queue/notifications.rb +72 -0
  14. data/lib/postjob/queue/search.rb +82 -0
  15. data/lib/postjob/queue.rb +331 -0
  16. data/lib/postjob/registry.rb +52 -0
  17. data/lib/postjob/runner.rb +153 -0
  18. data/lib/postjob/workflow.rb +60 -0
  19. data/lib/postjob.rb +170 -0
  20. data/spec/postjob/enqueue_spec.rb +86 -0
  21. data/spec/postjob/full_workflow_spec.rb +86 -0
  22. data/spec/postjob/job_control/manual_spec.rb +45 -0
  23. data/spec/postjob/job_control/max_attempts_spec.rb +70 -0
  24. data/spec/postjob/job_control/timeout_spec.rb +31 -0
  25. data/spec/postjob/job_control/workflow_status_spec.rb +52 -0
  26. data/spec/postjob/process_job_spec.rb +25 -0
  27. data/spec/postjob/queue/encoder_spec.rb +46 -0
  28. data/spec/postjob/queue/search_spec.rb +141 -0
  29. data/spec/postjob/run_spec.rb +69 -0
  30. data/spec/postjob/step_spec.rb +26 -0
  31. data/spec/postjob/sub_workflow_spec.rb +27 -0
  32. data/spec/spec_helper.rb +35 -0
  33. data/spec/support/configure_active_record.rb +18 -0
  34. data/spec/support/configure_database.rb +19 -0
  35. data/spec/support/configure_simple_sql.rb +17 -0
  36. data/spec/support/connect_active_record.rb +6 -0
  37. data/spec/support/test_helper.rb +53 -0
  38. metadata +269 -0
data/lib/postjob.rb ADDED
@@ -0,0 +1,170 @@
1
+ require "expectation"
2
+ require "simple/sql"
3
+
4
+ require_relative "postjob/version"
5
+ require_relative "postjob/workflow"
6
+ require_relative "postjob/registry"
7
+ require_relative "postjob/job"
8
+ require_relative "postjob/error"
9
+ require_relative "postjob/queue"
10
+ require_relative "postjob/runner"
11
+
12
+ module Postjob
13
+ attr_accessor :logger
14
+ extend self
15
+
16
+ # In fast mode <tt>Postjob.run</tt> doesn't wait that long between retrying
17
+ # failed jobs. This mode is enabled by default during tests; and it can be
18
+ # enabled via "postjob run --fast"
19
+ #
20
+ # Note that fast mode should only be used during development and tests.
21
+ attr_accessor :fast_mode
22
+ self.fast_mode = false
23
+
24
+ def enqueue!(workflow, *args, queue: nil,
25
+ parent_id: nil,
26
+ max_attempts: nil,
27
+ timeout: nil,
28
+ version: nil,
29
+ tags: nil)
30
+ expect! queue => [nil, String]
31
+ expect! workflow => String
32
+ expect! parent_id => [nil, Integer]
33
+ expect! max_attempts => [nil, Integer]
34
+ expect! timeout => [nil, Numeric]
35
+ expect! tags => [nil, Hash]
36
+
37
+ tags = stringify_hash(tags) if tags
38
+ job = Queue.enqueue_job workflow, *args, queue: queue,
39
+ parent_id: parent_id,
40
+ max_attempts: max_attempts,
41
+ timeout: timeout,
42
+ tags: tags,
43
+ version: version
44
+ logger.info "Generated process #{job}"
45
+ job.id
46
+ end
47
+
48
+ private
49
+
50
+ def stringify_hash(hsh)
51
+ hsh.inject({}) do |r, (k, v)|
52
+ k = k.to_s if k.is_a?(Symbol)
53
+ r.update k => v
54
+ end
55
+ end
56
+
57
+ public
58
+
59
+ # process all waiting jobs.
60
+ #
61
+ # This method starts processing jobs, as long as there are some. It returns
62
+ # once no runnable jobs can be found anymore.
63
+ #
64
+ # Note that this method is not limited to the set of runnable jobs present
65
+ # when calling it; if running a job results in newly created runnable jobs
66
+ # these jobs will be processed as well.
67
+ #
68
+ # This method returns the number of processed jobs.
69
+ def process_all
70
+ run do |job|
71
+ !job.nil?
72
+ end
73
+ end
74
+
75
+ # processes many jobs.
76
+ #
77
+ # This method starts processing jobs, as long as there are some. If no
78
+ # jobs can be found this method waits until a job becomes available.
79
+ #
80
+ # After processing each job is yielded into the passed in block.
81
+ #
82
+ # This method continues until:
83
+ # a) the requested number of jobs (via the count: argument) was processed (note:
84
+ # repeated job executions due to rerunning jobs that slept or errored count
85
+ # multiple times), or
86
+ # b) the block yielded into returns false.
87
+ #
88
+ # This method returns the number of processed jobs.
89
+ def run(count: nil, &block)
90
+ # to run 10^12 jobs that would take 1 msecs each we would need, at least,
91
+ # 760 years - so this default should be fine. Also, someone should update
92
+ # the machine in the meantime :)
93
+ count ||= 1_000_000_000_000
94
+
95
+ processed_jobs_count = 0
96
+
97
+ loop do
98
+ processed_job = Postjob.step
99
+ processed_jobs_count += 1 if processed_job
100
+
101
+ break if processed_jobs_count >= count
102
+ break if block && (yield(processed_job) == false)
103
+
104
+ next if processed_job
105
+
106
+ Queue::Notifications.wait_for_new_job
107
+ end
108
+
109
+ processed_jobs_count
110
+ end
111
+
112
+ # Runs a single job
113
+ #
114
+ # This method tries to check out a runnable job. If it finds one the
115
+ # job is processed (via Postjob.process_job) and returned. If not,
116
+ # this method just returns nil.
117
+ def step
118
+ Queue.checkout_runnable do |job|
119
+ process_job job
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ # This method is called from tests. Otherwise it is supposed to be private.
126
+ # rubocop:disable Metrics/CyclomaticComplexity
127
+ def process_job(job) # :nodoc:
128
+ expect! job => Job
129
+
130
+ version, status, value = if job.timed_out
131
+ [job.workflow_version, :timeout, nil]
132
+ else
133
+ Runner.process_job(job)
134
+ end
135
+
136
+ if job.workflow_version != "" && version != job.workflow_version
137
+ raise "Integrity check failed: job's workflow version changed (from #{job.workflow_version} to #{version})"
138
+ end
139
+
140
+ expect! version => String
141
+
142
+ case status
143
+ when :failed then Queue.set_job_error job, *value, status: :failed, version: version
144
+ when :err then Queue.set_job_error job, *value, status: :err, version: version
145
+ when :timeout then Queue.set_job_error job, "Timeout", "Timeout", status: :timeout, version: version
146
+ when :pending then Queue.set_job_pending job, version: version
147
+ when :ok then Queue.set_job_result job, value, version: version
148
+ else raise ArgumentError, "Invalid status #{status.inspect}"
149
+ end
150
+
151
+ [status, value]
152
+ end
153
+
154
+ public
155
+
156
+ def resolve(token:, result:)
157
+ job = Queue.find_job_by_token(token)
158
+ Queue.set_job_result job, result, version: nil
159
+ end
160
+
161
+ def register_workflow(workflow, options = {})
162
+ expect! options => {
163
+ version: [nil, /^\d+(\.\d+)*/]
164
+ }
165
+
166
+ workflow.include Postjob::Workflow
167
+ workflow.send(:set_workflow_version, options[:version] || "0.0")
168
+ Registry.register workflow, options
169
+ end
170
+ end
@@ -0,0 +1,86 @@
1
+ # rubocop:disable Metrics/BlockLength
2
+
3
+ require "spec_helper"
4
+
5
+ describe "Postjob.enqueue!" do
6
+ include TestHelper
7
+
8
+ context "with valid arguments" do
9
+ it "creates a job" do
10
+ Postjob.enqueue! "FooishWorkflow"
11
+ expect(TestHelper.jobs_count).to eq(1)
12
+ end
13
+
14
+ it "returns the created job" do
15
+ id = Postjob.enqueue! "FooishWorkflow"
16
+ newest_job = TestHelper.newest_job
17
+ expect(newest_job.id).to eq(id)
18
+ end
19
+
20
+ it "uses the parent id and sets a full id" do
21
+ id1 = Postjob.enqueue! "FooishWorkflow"
22
+ id2 = Postjob.enqueue! "FooishWorkflow", parent_id: id1
23
+ id3 = Postjob.enqueue! "FooishWorkflow", parent_id: id2
24
+
25
+ job1 = load_job id1
26
+ job2 = load_job id2
27
+ job3 = load_job id3
28
+
29
+ expect(job3.full_id).to eq("#{job1.id}.#{job2.id}.#{job3.id}")
30
+ expect(job3.full_id).to match(/\d+\.\d+\.\d+/)
31
+ end
32
+
33
+ it "sets arguments" do
34
+ id1 = Postjob.enqueue! "FooishWorkflow", 1, "two", "three"
35
+ job1 = load_job id1
36
+ expect(job1.args).to eq([1, "two", "three"])
37
+
38
+ id2 = Postjob.enqueue! "FooishWorkflow"
39
+ job2 = load_job id2
40
+ expect(job2.args).to eq([])
41
+ end
42
+
43
+ it "sets queue" do
44
+ id1 = Postjob.enqueue! "FooishWorkflow"
45
+ job1 = load_job id1
46
+ expect(job1.queue).to eq("q")
47
+
48
+ id1 = Postjob.enqueue! "FooishWorkflow", queue: "bla"
49
+ job1 = load_job id1
50
+ expect(job1.queue).to eq("bla")
51
+ end
52
+
53
+ it "sets max_attempts" do
54
+ id1 = Postjob.enqueue! "FooishWorkflow"
55
+ job1 = load_job id1
56
+ expect(job1.max_attempts).to eq(5)
57
+
58
+ id1 = Postjob.enqueue! "FooishWorkflow", max_attempts: 2
59
+ job1 = load_job id1
60
+ expect(job1.max_attempts).to eq(2)
61
+ end
62
+
63
+ it "sets the status to ready" do
64
+ id1 = Postjob.enqueue! "FooishWorkflow", 1, "two", "three"
65
+ job1 = load_job id1
66
+ expect(job1.status).to eq("ready")
67
+ end
68
+ end
69
+
70
+ context "with invalid arguments" do
71
+ it "raises an error with missing args" do
72
+ expect { Postjob.enqueue! queue: "q" }.to raise_error(ArgumentError)
73
+ expect { Postjob.enqueue! workflow: "q" }.to raise_error(ArgumentError)
74
+ end
75
+
76
+ it "raises an error with invalid args" do
77
+ expect { Postjob.enqueue! queue: 1, workflow: 13 }.to raise_error(ArgumentError)
78
+ end
79
+
80
+ it "raises an error with invalid parent_id" do
81
+ id1 = Postjob.enqueue! "FooishWorkflow"
82
+
83
+ expect { Postjob.enqueue! "FooishWorkflow", parent_id: id1 - 1 }.to raise_error(PG::ForeignKeyViolation)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,86 @@
1
+ require "spec_helper"
2
+
3
+ #
4
+ # This module simulates the MPX services interfaces, with a certain
5
+ # change not to be able to run (see operational_error?)
6
+ module MPX
7
+ module Impl
8
+ extend self
9
+
10
+ def self.load_users(user_ids)
11
+ user_ids.map do |user_id|
12
+ {
13
+ "type" => "User",
14
+ "id" => user_id
15
+ }
16
+ end
17
+ end
18
+
19
+ def self.clone_group(group_id)
20
+ group_id + 1000
21
+ end
22
+
23
+ def self.send_email(_user, _cloned_group_id)
24
+ "sent"
25
+ end
26
+ end
27
+
28
+ def self.method_missing(sym, *args)
29
+ operational_error?
30
+ Impl.send sym, *args
31
+ end
32
+
33
+ # The operational_error method introduces a certain chance of not being able
34
+ # to run one of the MPX.* methods, simulating a network timeout or other such
35
+ # error condition.
36
+ def self.operational_error?
37
+ raise "please rerun" if rand < 0.1
38
+ end
39
+ end
40
+
41
+ module RecommendGroupWorkflow
42
+ Postjob.register_workflow self
43
+
44
+ def self.clone_group(group_id)
45
+ MPX.clone_group(group_id)
46
+ end
47
+
48
+ def self.load_users(user_ids)
49
+ MPX.load_users(user_ids)
50
+ end
51
+
52
+ def self.send_email(user, cloned_group_id)
53
+ MPX.send_email user, cloned_group_id
54
+ end
55
+
56
+ def self.run(group_id, user_ids)
57
+ users = await :load_users, user_ids
58
+
59
+ cloned_group_id = await :clone_group, group_id
60
+ users.each do |user|
61
+ async :send_email, user, cloned_group_id
62
+ end
63
+ await :all
64
+ users.count
65
+ end
66
+ end
67
+
68
+ describe "Sub Workflows" do
69
+ include TestHelper
70
+
71
+ let!(:users_count) { 100 }
72
+ let!(:user_ids) { 100.upto(100 + users_count - 1).to_a }
73
+ let!(:id) { Postjob.enqueue! "RecommendGroupWorkflow", 1, user_ids }
74
+
75
+ it "runs the job returning the result" do
76
+ expect(MPX::Impl).to receive(:clone_group).exactly(1).times.and_call_original
77
+ expect(MPX::Impl).to receive(:load_users).exactly(1).times.and_call_original
78
+ expect(MPX::Impl).to receive(:send_email).exactly(users_count).times.and_call_original
79
+
80
+ Postjob.process_all
81
+
82
+ print_jobs
83
+ processed_job = load_job(id)
84
+ expect(processed_job.status).to eq("ok")
85
+ end
86
+ end
@@ -0,0 +1,45 @@
1
+ require "spec_helper"
2
+
3
+ module ManualWorkflow
4
+ def self.run
5
+ job = async :manual, timeout: 10
6
+ _token = workflow_token job
7
+ manual_result = await job
8
+ "manual-result:#{manual_result}"
9
+ end
10
+
11
+ Postjob.register_workflow self
12
+ end
13
+
14
+ describe "manual processing" do
15
+ let!(:id) { Postjob.enqueue! "ManualWorkflow" }
16
+
17
+ include TestHelper
18
+
19
+ before do
20
+ Postjob.process_all
21
+ end
22
+
23
+ def load_child_job
24
+ TestHelper.load_job "SELECT * FROM postjobs WHERE parent_id=$1", id
25
+ end
26
+
27
+ def token
28
+ load_token(load_child_job)
29
+ end
30
+
31
+ it "creates a token" do
32
+ expect! token => /[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[a-fA-F0-9]{4}-[A-F0-9]{12}/i
33
+ end
34
+
35
+ it "the token can be used to resolve the load_child_job" do
36
+ Postjob.resolve token: token, result: "foobar"
37
+ expect(load_child_job.status).to eq("ok")
38
+ expect(load_child_job.result).to eq("foobar")
39
+
40
+ Postjob.process_all
41
+ job = load_job id
42
+ expect(job.status).to eq("ok")
43
+ expect(job.result).to eq("manual-result:foobar")
44
+ end
45
+ end
@@ -0,0 +1,70 @@
1
+ # rubocop:disable Metrics/BlockLength
2
+
3
+ require "spec_helper"
4
+
5
+ module MaxAttemptWorkflow
6
+ module ChildWorker
7
+ def self.run
8
+ raise "Nono nono!"
9
+ end
10
+
11
+ Postjob.register_workflow self
12
+ end
13
+
14
+ def self.run(max_attempts)
15
+ set_workflow_status "starting up"
16
+ await async ChildWorker, max_attempts: max_attempts
17
+ set_workflow_status "starting finished"
18
+ end
19
+
20
+ Postjob.register_workflow self
21
+ end
22
+
23
+ describe "max_attempts" do
24
+ before do
25
+ Simple::SQL.all("DELETE FROM postjob.postjobs")
26
+ end
27
+
28
+ def load_child_job
29
+ TestHelper.load_job "SELECT * FROM postjobs WHERE parent_id=$1", id
30
+ end
31
+
32
+ def load_job
33
+ TestHelper.load_job(id)
34
+ end
35
+
36
+ def load_child_job
37
+ TestHelper.load_job "SELECT * FROM postjobs WHERE parent_id=$1", id
38
+ end
39
+
40
+ context "when running with max_attempts 1" do
41
+ let!(:id) { Postjob.enqueue!("MaxAttemptWorkflow", 1, max_attempts: 1) }
42
+
43
+ it "fails the childjob with a timeout" do
44
+ while Postjob.process_all > 0
45
+ sleep 0.03
46
+ end
47
+
48
+ expect(load_child_job.failed_attempts).to eq(1)
49
+ expect(load_child_job.status).to eq("failed")
50
+
51
+ expect(load_job.status).to eq("failed")
52
+ end
53
+ end
54
+
55
+ context "when running with max_attempts 5" do
56
+ let!(:id) { Postjob.enqueue!("MaxAttemptWorkflow", 5, max_attempts: 1) }
57
+
58
+ it "fails the childjob with a timeout" do
59
+ while Postjob.process_all > 0
60
+ sleep 0.03
61
+ end
62
+
63
+ expect(load_child_job.failed_attempts).to eq(5)
64
+ expect(load_child_job.status).to eq("failed")
65
+
66
+ # The main job now failed. It only ran with max_attempts: 1
67
+ expect(load_job.status).to eq("failed")
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,31 @@
1
+ require "spec_helper"
2
+
3
+ module FastFailWorkflow
4
+ def self.run
5
+ await async :manual, timeout: 0.000001
6
+ end
7
+
8
+ Postjob.register_workflow self
9
+ end
10
+
11
+ describe "timeout" do
12
+ context "with an instant timeout" do
13
+ let!(:id) { Postjob.enqueue! "FastFailWorkflow" }
14
+
15
+ before do
16
+ Postjob.process_all
17
+ end
18
+
19
+ it "fails the childjob with a timeout" do
20
+ manual_job = TestHelper.load_job "SELECT * FROM postjobs WHERE parent_id=$1", id
21
+ expect(manual_job.status).to eq("timeout")
22
+ expect { manual_job.resolve }.to raise_error(Timeout::Error)
23
+ end
24
+
25
+ xit "fails the mainjob" do
26
+ job = TestHelper.load_job(id)
27
+ expect(job.status).to eq("err")
28
+ expect { job.resolve }.to raise_error(Postjob::Error)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,52 @@
1
+ require "spec_helper"
2
+
3
+ module WorkflowStatusWorkflow
4
+ def self.run
5
+ set_workflow_status "starting-up"
6
+
7
+ job = async :manual, timeout: 10
8
+ set_workflow_status "created-manual"
9
+
10
+ _token = workflow_token job
11
+ set_workflow_status "created-manual-token"
12
+
13
+ manual_result = await job
14
+ set_workflow_status "got-manual-token"
15
+
16
+ "manual-result:#{manual_result}"
17
+ end
18
+
19
+ Postjob.register_workflow self
20
+ end
21
+
22
+ describe "workflow_status" do
23
+ let!(:id) { Postjob.enqueue! "WorkflowStatusWorkflow" }
24
+
25
+ include TestHelper
26
+
27
+ before do
28
+ Postjob.process_all
29
+ end
30
+
31
+ def load_child_job
32
+ TestHelper.load_job "SELECT * FROM postjobs WHERE parent_id=$1", id
33
+ end
34
+
35
+ def token
36
+ load_token(load_child_job)
37
+ end
38
+
39
+ it "creates a token" do
40
+ expect! token => /[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[a-fA-F0-9]{4}-[A-F0-9]{12}/i
41
+ end
42
+
43
+ it "the token can be used to resolve the load_child_job" do
44
+ Postjob.resolve token: token, result: "foobar"
45
+ expect(load_child_job.status).to eq("ok")
46
+ expect(load_job(id).workflow_status).to eq("created-manual-token")
47
+
48
+ Postjob.process_all
49
+ expect(load_job(id).status).to eq("ok")
50
+ expect(load_job(id).workflow_status).to eq("got-manual-token")
51
+ end
52
+ end
@@ -0,0 +1,25 @@
1
+ require "spec_helper"
2
+
3
+ module BarWorkflow
4
+ def self.run
5
+ "Foo"
6
+ end
7
+
8
+ Postjob.register_workflow self, version: "1.2"
9
+ end
10
+
11
+ describe "Postjob.process_job" do
12
+ include TestHelper
13
+
14
+ let!(:job) { Postjob.enqueue! "BarWorkflow" }
15
+
16
+ it "runs the job returning the result" do
17
+ expect(Postjob.process_job(newest_job)).to eq([:ok, "Foo"])
18
+ end
19
+
20
+ it "updates the job version" do
21
+ expect(newest_job.workflow_version).to eq("")
22
+ Postjob.process_job(newest_job)
23
+ expect(newest_job.workflow_version).to eq("1.2")
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ require "spec_helper"
2
+
3
+ module EncodeSpecWorkflow
4
+ def self.run(_inp, out)
5
+ out
6
+ end
7
+
8
+ Postjob.register_workflow self
9
+ end
10
+
11
+ describe "Postjob::Queue::Encoder" do
12
+ def run_workflow(input: "dummy input", output: "dummy output")
13
+ id = Postjob.enqueue! "EncodeSpecWorkflow", input, output
14
+ Postjob.process_all
15
+ job = TestHelper.load_job(id)
16
+ job.result
17
+ end
18
+
19
+ it "allows encoding of numbers" do
20
+ expect(run_workflow(input: 10, output: 12)).to eq(12)
21
+ end
22
+
23
+ it "allows encoding of booleans" do
24
+ expect(run_workflow(input: true, output: false)).to eq(false)
25
+ end
26
+
27
+ it "allows encoding of strings" do
28
+ expect(run_workflow(input: "foo", output: "bar")).to eq("bar")
29
+ end
30
+
31
+ it "allows encoding of arrays" do
32
+ expect(run_workflow(input: ["foo", "foo"], output: ["bar", "baz"])).to eq(["bar", "baz"])
33
+ end
34
+
35
+ describe "invalid input" do
36
+ it "cannot encode Symbols on input" do
37
+ expect do
38
+ run_workflow input: :foo
39
+ end.to raise_error(ArgumentError)
40
+
41
+ expect do
42
+ run_workflow output: :foo
43
+ end.to raise_error(ArgumentError)
44
+ end
45
+ end
46
+ end