postjob 0.1.1

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 (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