postjob 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +23 -0
- data/bin/postjob +11 -0
- data/lib/postjob/cli/db.rb +39 -0
- data/lib/postjob/cli/job.rb +67 -0
- data/lib/postjob/cli/ps.rb +110 -0
- data/lib/postjob/cli/run.rb +19 -0
- data/lib/postjob/cli.rb +31 -0
- data/lib/postjob/error.rb +16 -0
- data/lib/postjob/job.rb +66 -0
- data/lib/postjob/migrations.rb +97 -0
- data/lib/postjob/queue/encoder.rb +40 -0
- data/lib/postjob/queue/notifications.rb +72 -0
- data/lib/postjob/queue/search.rb +82 -0
- data/lib/postjob/queue.rb +331 -0
- data/lib/postjob/registry.rb +52 -0
- data/lib/postjob/runner.rb +153 -0
- data/lib/postjob/workflow.rb +60 -0
- data/lib/postjob.rb +170 -0
- data/spec/postjob/enqueue_spec.rb +86 -0
- data/spec/postjob/full_workflow_spec.rb +86 -0
- data/spec/postjob/job_control/manual_spec.rb +45 -0
- data/spec/postjob/job_control/max_attempts_spec.rb +70 -0
- data/spec/postjob/job_control/timeout_spec.rb +31 -0
- data/spec/postjob/job_control/workflow_status_spec.rb +52 -0
- data/spec/postjob/process_job_spec.rb +25 -0
- data/spec/postjob/queue/encoder_spec.rb +46 -0
- data/spec/postjob/queue/search_spec.rb +141 -0
- data/spec/postjob/run_spec.rb +69 -0
- data/spec/postjob/step_spec.rb +26 -0
- data/spec/postjob/sub_workflow_spec.rb +27 -0
- data/spec/spec_helper.rb +35 -0
- data/spec/support/configure_active_record.rb +18 -0
- data/spec/support/configure_database.rb +19 -0
- data/spec/support/configure_simple_sql.rb +17 -0
- data/spec/support/connect_active_record.rb +6 -0
- data/spec/support/test_helper.rb +53 -0
- metadata +269 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 93400c11de9310d973cb74ef6912d5e4190d5950
|
4
|
+
data.tar.gz: b505261dc9cb3a2aacf905e8f37027c07ff78946
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fe6794cfc9b47039aa6f57381f9465b30a15e7b7369b6eb0ec051acfa9c2786ae8a52fbc0cd71b1e450824e61238e18e892e69b7ef9611247eb7c5a16f0ea0b3
|
7
|
+
data.tar.gz: eeb6632e7cebbd95f96d801c8b67a3aea53e5a5c8960801f7292cf22aa62c2986fbcb60eb8564cf545a29f63c35f311057bfbb36887a298a4db5cabfd6be17e3
|
data/README.md
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# Postjob
|
2
|
+
|
3
|
+
The `postjob` gem implements a simple way to have restartable, asynchronous, and distributed processes.
|
4
|
+
|
5
|
+
## Development
|
6
|
+
|
7
|
+
After checking out the repo, run `bin/setup` to install dependencies. Make sure you have a local postgresql implementation of at least version 9.5. Add a `postqueue` user with a `postqueue` password, and create a `postqueue_test` database for it. The script `./scripts/prepare_pg` can be somewhat helpful in establishing that.
|
8
|
+
|
9
|
+
Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
10
|
+
|
11
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
12
|
+
|
13
|
+
To release a new version, run `./scripts/release`, which will bump the version number, create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
14
|
+
|
15
|
+
## Contributing
|
16
|
+
|
17
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/postqueue.
|
18
|
+
|
19
|
+
|
20
|
+
## License
|
21
|
+
|
22
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
23
|
+
|
data/bin/postjob
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require "postjob/cli"
|
2
|
+
|
3
|
+
module Postjob::CLI
|
4
|
+
def db_migrate
|
5
|
+
require "postjob/migrations"
|
6
|
+
|
7
|
+
connect_to_database!
|
8
|
+
Postjob::Migrations.migrate!
|
9
|
+
end
|
10
|
+
|
11
|
+
def db_unmigrate
|
12
|
+
require "postjob/migrations"
|
13
|
+
|
14
|
+
connect_to_database!
|
15
|
+
Postjob::Migrations.unmigrate!
|
16
|
+
end
|
17
|
+
|
18
|
+
def db_remigrate
|
19
|
+
require "postjob/migrations"
|
20
|
+
|
21
|
+
connect_to_database!
|
22
|
+
Postjob::Migrations.unmigrate!
|
23
|
+
Postjob::Migrations.migrate!
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
USE_ACTIVE_RECORD = false
|
29
|
+
|
30
|
+
def connect_to_database!
|
31
|
+
if USE_ACTIVE_RECORD
|
32
|
+
require "active_record"
|
33
|
+
abc = ::Simple::SQL::Config.read_database_yml
|
34
|
+
::ActiveRecord::Base.establish_connection abc
|
35
|
+
else
|
36
|
+
::Simple::SQL.connect!
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module Postjob::CLI
|
2
|
+
# Enqueues a workflow
|
3
|
+
#
|
4
|
+
# Adds a workflow to the job table, with name <workflow> and the given
|
5
|
+
# arguments.
|
6
|
+
#
|
7
|
+
# Note that the workflow will receive the arguments as strings and must be
|
8
|
+
# prepared to handle these.
|
9
|
+
def job_enqueue(workflow, *args, queue: "ruby", tags: nil)
|
10
|
+
connect_to_database!
|
11
|
+
|
12
|
+
Postjob.enqueue! workflow, *args, queue: queue, tags: parse_tags(tags)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Reset failed jobs
|
16
|
+
#
|
17
|
+
# This resets all failed jobs within the job tree, below the passed in
|
18
|
+
# job id.
|
19
|
+
def job_reset(job_id)
|
20
|
+
job_id = Integer(job_id)
|
21
|
+
full_job_id = Simple::SQL.ask "SELECT full_id FROM postjob.postjobs WHERE id=$1", job_id
|
22
|
+
full_job_id || logger.error("No such job: #{job_id}")
|
23
|
+
|
24
|
+
job_ids = Simple::SQL.all <<~SQL
|
25
|
+
SELECT id FROM postjob.postjobs
|
26
|
+
WHERE (full_id LIKE '#{full_job_id}.%' OR full_id='#{full_job_id}')
|
27
|
+
AND status IN ('failed', 'err', 'timeout')
|
28
|
+
SQL
|
29
|
+
|
30
|
+
logger.warn "Affected jobs: #{job_ids.count}"
|
31
|
+
return if job_ids.empty?
|
32
|
+
|
33
|
+
Simple::SQL.ask <<~SQL, job_ids
|
34
|
+
UPDATE postjob.postjobs
|
35
|
+
SET
|
36
|
+
status='ready', next_run_at=now(),
|
37
|
+
results=null, failed_attempts=0, error=NULL, error_message=NULL, error_backtrace=NULL
|
38
|
+
WHERE id = ANY($1);
|
39
|
+
SQL
|
40
|
+
|
41
|
+
Simple::SQL.ask <<~SQL
|
42
|
+
NOTIFY postjob_notifications
|
43
|
+
SQL
|
44
|
+
|
45
|
+
logger.warn "The following jobs have been reset: #{job_ids.join(", ")}"
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
# parses "foo:bar,baz:quibble" into { "foo" => "bar", "baz" => "quibble"}
|
51
|
+
def parse_tags(tags)
|
52
|
+
return nil unless tags
|
53
|
+
tags.split(",").inject({}) do |hsh, tag|
|
54
|
+
expect! tag => /\A[^:]+:[^:]+\z/
|
55
|
+
k, v = tag.split(":", 2)
|
56
|
+
hsh.update k => v
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
public
|
61
|
+
|
62
|
+
def registry
|
63
|
+
workflows = Postjob::Registry.workflows
|
64
|
+
names = workflows.map(&:workflow_name)
|
65
|
+
puts names.sort.join("\n")
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
module Postjob::CLI
|
2
|
+
private
|
3
|
+
|
4
|
+
def ps_query(conditions = [])
|
5
|
+
conditions.compact!
|
6
|
+
|
7
|
+
conditions << "TRUE"
|
8
|
+
condition_fragment = conditions
|
9
|
+
.compact
|
10
|
+
.map { |s| "(#{s})" }
|
11
|
+
.join(" AND ")
|
12
|
+
|
13
|
+
<<~SQL
|
14
|
+
SELECT
|
15
|
+
id,
|
16
|
+
full_id,
|
17
|
+
workflow
|
18
|
+
|| (CASE WHEN workflow_version != '' THEN '@' ELSE '' END)
|
19
|
+
|| workflow_version
|
20
|
+
|| (CASE WHEN workflow_method != 'run' THEN '.' || workflow_method ELSE '' END)
|
21
|
+
|| args AS job,
|
22
|
+
workflow_status,
|
23
|
+
status,
|
24
|
+
error,
|
25
|
+
COALESCE((results->0)::varchar, error_message) AS result,
|
26
|
+
next_run_at,
|
27
|
+
error_backtrace,
|
28
|
+
(now() at time zone 'utc') - created_at AS age,
|
29
|
+
updated_at - created_at AS runtime,
|
30
|
+
tags
|
31
|
+
FROM postjob.postjobs
|
32
|
+
WHERE #{condition_fragment}
|
33
|
+
ORDER BY root_id DESC, id ASC
|
34
|
+
SQL
|
35
|
+
end
|
36
|
+
|
37
|
+
def tags_condition(tags)
|
38
|
+
return nil unless tags
|
39
|
+
|
40
|
+
kv = parse_tags(tags)
|
41
|
+
"tags @> '#{Postjob::Queue::Encoder.encode(kv)}'"
|
42
|
+
end
|
43
|
+
|
44
|
+
public
|
45
|
+
|
46
|
+
# Show job status
|
47
|
+
#
|
48
|
+
# This command lists the statuses of all jobs that are either root jobs,
|
49
|
+
# i.e. enqueued workflows, or that have failed.
|
50
|
+
#
|
51
|
+
# Example:
|
52
|
+
#
|
53
|
+
# postjob ps --tags=foo:bar,bar:baz --limit=100
|
54
|
+
#
|
55
|
+
# For a listing of all jobs in the system use ps:full, see 'postjob help ps:full'
|
56
|
+
# for details.
|
57
|
+
def ps(*ids, limit: "100", tags: nil)
|
58
|
+
expect! limit => /\A\d+\z/
|
59
|
+
limit = Integer(limit)
|
60
|
+
|
61
|
+
unless ids.empty?
|
62
|
+
ps_full *ids, limit: limit, tags: tags
|
63
|
+
return
|
64
|
+
end
|
65
|
+
|
66
|
+
conditions = []
|
67
|
+
conditions << "root_id=id OR status NOT IN ('ready', 'sleep', 'ok')"
|
68
|
+
conditions << tags_condition(tags)
|
69
|
+
conditions << ids_condition(ids)
|
70
|
+
|
71
|
+
query = ps_query(conditions)
|
72
|
+
print_sql limit: limit, query: query
|
73
|
+
end
|
74
|
+
|
75
|
+
def ps_full(*ids, limit: 100, tags: nil)
|
76
|
+
conditions = []
|
77
|
+
conditions << tags_condition(tags)
|
78
|
+
conditions << ids_condition(ids)
|
79
|
+
|
80
|
+
query = ps_query(conditions)
|
81
|
+
|
82
|
+
limit = Integer(limit)
|
83
|
+
print_sql limit: limit, query: query
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def parse_ids(*ids)
|
89
|
+
return [] if ids.empty?
|
90
|
+
ids.flatten.inject([]) { |a, ids_string| a.concat ids_string.split(",") }
|
91
|
+
.map { |p| Integer(p) }
|
92
|
+
.uniq
|
93
|
+
end
|
94
|
+
|
95
|
+
def ids_condition(ids)
|
96
|
+
ids = parse_ids(ids)
|
97
|
+
return nil if ids.empty?
|
98
|
+
"root_id IN (#{ids.join(',')})"
|
99
|
+
end
|
100
|
+
|
101
|
+
def print_sql(limit:, query:)
|
102
|
+
connect_to_database!
|
103
|
+
records = Simple::SQL.records("#{query} LIMIT $1+1", limit)
|
104
|
+
|
105
|
+
tp records[0, limit]
|
106
|
+
if records.length > limit
|
107
|
+
logger.warn "Output limited up to limit #{limit}. Use the --limit command line option for a different limit."
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Postjob::CLI
|
2
|
+
def step
|
3
|
+
run count: 1
|
4
|
+
end
|
5
|
+
|
6
|
+
def run(count: nil, fast: false, quiet: false)
|
7
|
+
count = Integer(count) if count
|
8
|
+
Postjob.fast_mode = fast
|
9
|
+
|
10
|
+
connect_to_database!
|
11
|
+
|
12
|
+
processed = Postjob.run(count: count) do |job|
|
13
|
+
logger.info "Processed job w/id #{job.id}" if job
|
14
|
+
STDERR.print "." unless quiet
|
15
|
+
end
|
16
|
+
|
17
|
+
logger.info "Processed #{processed} jobs"
|
18
|
+
end
|
19
|
+
end
|
data/lib/postjob/cli.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require "simple/cli"
|
2
|
+
require "table_print"
|
3
|
+
|
4
|
+
Dir.glob("#{File.dirname(__FILE__)}/cli/**/*.rb").sort.each do |path|
|
5
|
+
load(path)
|
6
|
+
end
|
7
|
+
|
8
|
+
module Postjob::CLI
|
9
|
+
include ::Simple::CLI
|
10
|
+
|
11
|
+
def run!(command, *args)
|
12
|
+
Postjob.logger = logger
|
13
|
+
load_environment!
|
14
|
+
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def load_environment(path)
|
21
|
+
return unless File.exist?(path)
|
22
|
+
|
23
|
+
logger.warn "#{path}: loading Postjob configuration"
|
24
|
+
load path
|
25
|
+
end
|
26
|
+
|
27
|
+
def load_environment!
|
28
|
+
load_environment("config/environment.rb")
|
29
|
+
load_environment("config/postjob.rb")
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Postjob
|
2
|
+
class Error < RuntimeError
|
3
|
+
def initialize(job)
|
4
|
+
@job = job
|
5
|
+
end
|
6
|
+
|
7
|
+
def message
|
8
|
+
msg = "Failing child job"
|
9
|
+
msg += " [#{@job.result}]" if @job.result
|
10
|
+
msg
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class Error::Nonrecoverable < Error
|
15
|
+
end
|
16
|
+
end
|
data/lib/postjob/job.rb
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
#
|
2
|
+
# A job class in-memory representation.
|
3
|
+
#
|
4
|
+
class Postjob::Job < Hash
|
5
|
+
def initialize(hsh)
|
6
|
+
replace hsh.dup
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.attribute(sym)
|
10
|
+
eval <<~RUBY
|
11
|
+
define_method(:#{sym}) { self[:#{sym}] }
|
12
|
+
RUBY
|
13
|
+
end
|
14
|
+
|
15
|
+
attribute :id
|
16
|
+
attribute :parent_id
|
17
|
+
attribute :full_id
|
18
|
+
attribute :root_id
|
19
|
+
attribute :created_at
|
20
|
+
attribute :queue
|
21
|
+
attribute :workflow
|
22
|
+
attribute :workflow_method
|
23
|
+
attribute :workflow_version
|
24
|
+
attribute :args
|
25
|
+
attribute :next_run_at
|
26
|
+
attribute :timing_out_at
|
27
|
+
attribute :failed_attempts
|
28
|
+
attribute :max_attempts
|
29
|
+
attribute :status
|
30
|
+
attribute :results
|
31
|
+
attribute :error
|
32
|
+
attribute :error_message
|
33
|
+
attribute :error_backtrace
|
34
|
+
attribute :recipients
|
35
|
+
attribute :workflow_status
|
36
|
+
attribute :timed_out
|
37
|
+
attribute :tags
|
38
|
+
|
39
|
+
STATUSES = %w(ok ready sleep err failed timeout)
|
40
|
+
|
41
|
+
def resolve
|
42
|
+
expect! status => STATUSES
|
43
|
+
|
44
|
+
case status
|
45
|
+
when "ok" then result
|
46
|
+
when "ready" then :pending
|
47
|
+
when "sleep" then :pending
|
48
|
+
when "timeout" then raise Timeout::Error
|
49
|
+
when "err" then :pending
|
50
|
+
when "failed" then raise Postjob::Error::Nonrecoverable, self
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def result
|
55
|
+
results && results.first
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_s
|
59
|
+
full_workflow = workflow
|
60
|
+
full_workflow += "@#{workflow_version}" if workflow_version != ""
|
61
|
+
full_workflow += ".#{workflow_method}" if workflow_method != "run"
|
62
|
+
|
63
|
+
args = (self.args || []).map(&:inspect).join(", ")
|
64
|
+
"Postjob##{full_id}: #{full_workflow}(#{args}) (#{status})"
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
module Postjob
|
2
|
+
module Migrations
|
3
|
+
extend self
|
4
|
+
|
5
|
+
SQL = ::Simple::SQL
|
6
|
+
SCHEMA_NAME = Postjob::Queue::SCHEMA_NAME
|
7
|
+
|
8
|
+
def unmigrate!
|
9
|
+
if SCHEMA_NAME != "public"
|
10
|
+
SQL.exec <<~SQL
|
11
|
+
DROP SCHEMA IF EXISTS #{SCHEMA_NAME} CASCADE;
|
12
|
+
SQL
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
PG_TYPES = <<~SQL
|
17
|
+
SELECT pg_namespace.nspname AS schema, pg_type.typname AS name
|
18
|
+
FROM pg_type
|
19
|
+
LEFT JOIN pg_namespace on pg_namespace.oid=pg_type.typnamespace
|
20
|
+
SQL
|
21
|
+
|
22
|
+
def migrate!
|
23
|
+
SQL.exec <<~SQL
|
24
|
+
CREATE SCHEMA IF NOT EXISTS #{SCHEMA_NAME};
|
25
|
+
SQL
|
26
|
+
|
27
|
+
unless SQL.ask("SELECT 1 FROM (#{PG_TYPES}) sq WHERE (schema,name) = ($1, $2)", SCHEMA_NAME, "statuses")
|
28
|
+
SQL.exec <<~SQL
|
29
|
+
CREATE TYPE #{SCHEMA_NAME}.statuses AS ENUM (
|
30
|
+
'ready', -- process can run
|
31
|
+
'sleep', -- process has external dependencies to wait for.
|
32
|
+
'failed', -- process failed, with nonrecoverable error
|
33
|
+
'err', -- process errored (with recoverable error)
|
34
|
+
'timeout', -- process timed out
|
35
|
+
'ok' -- process succeeded
|
36
|
+
);
|
37
|
+
SQL
|
38
|
+
end
|
39
|
+
|
40
|
+
SQL.exec <<~SQL
|
41
|
+
CREATE TABLE IF NOT EXISTS #{SCHEMA_NAME}.postjobs (
|
42
|
+
-- id values, readonly once created
|
43
|
+
id BIGSERIAL PRIMARY KEY, -- process id
|
44
|
+
parent_id BIGINT REFERENCES #{SCHEMA_NAME}.postjobs ON DELETE CASCADE, -- parent process id
|
45
|
+
full_id VARCHAR, -- full process id
|
46
|
+
root_id BIGINT, -- root process id
|
47
|
+
|
48
|
+
created_at timestamp NOT NULL DEFAULT (now() at time zone 'utc'), -- creation timestamp
|
49
|
+
updated_at timestamp NOT NULL DEFAULT (now() at time zone 'utc'), -- update timestamp
|
50
|
+
|
51
|
+
queue VARCHAR, -- queue name. (readonly)
|
52
|
+
workflow VARCHAR NOT NULL, -- e.g. "MyJobModule" (readonly)
|
53
|
+
workflow_method VARCHAR NOT NULL DEFAULT 'run', -- e.g. "run" (readonly)
|
54
|
+
workflow_version VARCHAR NOT NULL DEFAULT '', -- e.g. "1.0"
|
55
|
+
args JSONB, -- args
|
56
|
+
|
57
|
+
-- process state ----------------------------------------------------
|
58
|
+
|
59
|
+
status #{SCHEMA_NAME}.statuses DEFAULT 'ready',
|
60
|
+
next_run_at timestamp DEFAULT (now() at time zone 'utc'), -- when possible to run next?
|
61
|
+
timing_out_at timestamp, -- job times out after this timestamp
|
62
|
+
failed_attempts INTEGER NOT NULL DEFAULT 0, -- failed how often?
|
63
|
+
max_attempts INTEGER NOT NULL DEFAULT 1, -- maximum attempts before failing
|
64
|
+
|
65
|
+
-- process result ---------------------------------------------------
|
66
|
+
|
67
|
+
results JSONB,
|
68
|
+
error VARCHAR,
|
69
|
+
error_message VARCHAR,
|
70
|
+
error_backtrace JSONB,
|
71
|
+
|
72
|
+
-- custom fields
|
73
|
+
workflow_status VARCHAR,
|
74
|
+
tags JSONB
|
75
|
+
);
|
76
|
+
|
77
|
+
-- [TODO] check indices
|
78
|
+
CREATE INDEX IF NOT EXISTS postjobs_tags_idx
|
79
|
+
ON #{SCHEMA_NAME}.postjobs USING GIN (tags jsonb_path_ops);
|
80
|
+
CREATE INDEX IF NOT EXISTS postjobs_parent_id_idx
|
81
|
+
ON #{SCHEMA_NAME}.postjobs(parent_id);
|
82
|
+
SQL
|
83
|
+
|
84
|
+
SQL.exec <<~SQL
|
85
|
+
CREATE TABLE IF NOT EXISTS #{SCHEMA_NAME}.tokens (
|
86
|
+
id BIGSERIAL PRIMARY KEY,
|
87
|
+
postjob_id BIGINT REFERENCES #{SCHEMA_NAME}.postjobs ON DELETE CASCADE,
|
88
|
+
token UUID NOT NULL,
|
89
|
+
created_at timestamp NOT NULL DEFAULT (now() at time zone 'utc')
|
90
|
+
);
|
91
|
+
|
92
|
+
CREATE INDEX IF NOT EXISTS tokens_postjob_id_idx ON #{SCHEMA_NAME}.tokens(postjob_id);
|
93
|
+
CREATE INDEX IF NOT EXISTS tokens_token_idx ON #{SCHEMA_NAME}.tokens(token);
|
94
|
+
SQL
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# rubocop:disable Style/Documentation
|
2
|
+
module Postjob::Queue
|
3
|
+
end
|
4
|
+
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
#
|
8
|
+
# The Postjob::Queue::Encoder module wraps the JSON encoder, to ensure that only
|
9
|
+
# *our* data is encoded.
|
10
|
+
#
|
11
|
+
# Workflows should exclusively use Numbers, true, false, nil, Strings, Times and
|
12
|
+
# Dates, and Arrays and Hashes built of those.
|
13
|
+
#
|
14
|
+
# postjob does not support all data types supported by Ruby's "json" library.
|
15
|
+
# We do not support Symbols, but might also not support things like ActiveRecord
|
16
|
+
# objects etc.
|
17
|
+
module Postjob::Queue::Encoder
|
18
|
+
extend self
|
19
|
+
|
20
|
+
def encode(data)
|
21
|
+
verify_encodable!(data)
|
22
|
+
JSON.generate(data)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def verify_encodable!(obj)
|
28
|
+
case obj
|
29
|
+
when nil, true, false then :ok
|
30
|
+
when String then :ok
|
31
|
+
when Numeric then :ok
|
32
|
+
when Time, Date, DateTime then :ok
|
33
|
+
when Hash then verify_encodable!(obj.keys) && verify_encodable!(obj.values)
|
34
|
+
when Array then obj.each { |entry| verify_encodable!(entry) }
|
35
|
+
else
|
36
|
+
msg = "Unencodable #{obj.class.name} object: #{obj.inspect}"
|
37
|
+
raise ArgumentError, msg
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
#
|
2
|
+
# The Postjob::Queue manages enqueueing and fetching jobs from a job queue.
|
3
|
+
module Postjob::Queue::Notifications
|
4
|
+
extend self
|
5
|
+
|
6
|
+
SQL = ::Postjob::Queue::SQL
|
7
|
+
TABLE_NAME = ::Postjob::Queue::TABLE_NAME
|
8
|
+
CHANNEL = "postjob_notifications"
|
9
|
+
|
10
|
+
def notify_listeners
|
11
|
+
SQL.ask "NOTIFY #{CHANNEL}"
|
12
|
+
end
|
13
|
+
|
14
|
+
def wait_for_new_job
|
15
|
+
started_at = Time.now
|
16
|
+
|
17
|
+
start_listening
|
18
|
+
|
19
|
+
loop do
|
20
|
+
wait_time = time_to_next_job
|
21
|
+
break if wait_time && wait_time <= 0
|
22
|
+
|
23
|
+
wait_time ||= 120
|
24
|
+
Postjob.logger.debug "postjob: waiting for notification for up to #{time_to_next_job} seconds"
|
25
|
+
break if Simple::SQL.wait_for_notify(wait_time)
|
26
|
+
end
|
27
|
+
|
28
|
+
# flush notifications. It is possible that a huge number of notifications
|
29
|
+
# piled up while we have been waiting. The following line takes care of
|
30
|
+
# those.
|
31
|
+
while Simple::SQL.wait_for_notify(0.000001)
|
32
|
+
:nop
|
33
|
+
end
|
34
|
+
|
35
|
+
Postjob.logger.debug "postjob: awoke after #{format('%.03f secs', (Time.now - started_at))}"
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def start_listening
|
41
|
+
return if @is_listening
|
42
|
+
|
43
|
+
Simple::SQL.ask "LISTEN #{CHANNEL}"
|
44
|
+
@is_listening = true
|
45
|
+
end
|
46
|
+
|
47
|
+
# returns the maximum number of seconds to wait until the
|
48
|
+
# next runnable or timeoutable job comes up.
|
49
|
+
def time_to_next_job
|
50
|
+
queries = []
|
51
|
+
|
52
|
+
escaped_workflows_and_versions = Postjob::Registry.sql_escaped_workflows_and_versions
|
53
|
+
if escaped_workflows_and_versions != ""
|
54
|
+
queries.push <<~SQL
|
55
|
+
SELECT
|
56
|
+
EXTRACT(EPOCH FROM MIN(next_run_at) - (now() at time zone 'utc'))
|
57
|
+
FROM #{TABLE_NAME}
|
58
|
+
WHERE status = 'ready' AND ((workflow, workflow_version) IN (#{escaped_workflows_and_versions}))
|
59
|
+
SQL
|
60
|
+
end
|
61
|
+
|
62
|
+
queries.push <<~SQL
|
63
|
+
SELECT
|
64
|
+
EXTRACT(EPOCH FROM MIN(timing_out_at) - (now() at time zone 'utc'))
|
65
|
+
FROM #{TABLE_NAME}
|
66
|
+
WHERE status IN ('ready', 'sleep')
|
67
|
+
SQL
|
68
|
+
|
69
|
+
timestamps = Simple::SQL.all(queries.join(" UNION "))
|
70
|
+
timestamps.compact.min
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# rubocop:disable Style/Documentation
|
2
|
+
module Postjob::Queue
|
3
|
+
end
|
4
|
+
|
5
|
+
#
|
6
|
+
# The Postjob::Queue::Search module is able to inspect the Postjob Queue.
|
7
|
+
module Postjob::Queue::Search
|
8
|
+
extend self
|
9
|
+
|
10
|
+
def one(id, filter: {}, into: nil)
|
11
|
+
query = query(page: 0, per: 1, filter: filter, id: id)
|
12
|
+
Simple::SQL.record(query, into: into)
|
13
|
+
end
|
14
|
+
|
15
|
+
def all(page: 0, per: 100, filter: {}, into: nil)
|
16
|
+
query = query(page: page, per: per, filter: filter)
|
17
|
+
Simple::SQL.records(query, into: into)
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def query(page: nil, per: nil, filter: {}, root_only: true, id: nil)
|
23
|
+
expect! id => [Integer, nil]
|
24
|
+
expect! page => [Integer, nil]
|
25
|
+
expect! per => [Integer, nil]
|
26
|
+
expect! { page >= 0 && per > 0 }
|
27
|
+
expect! filter => Hash
|
28
|
+
|
29
|
+
conditions = []
|
30
|
+
conditions << "id=#{id}" if id
|
31
|
+
conditions << "root_id=id" if root_only
|
32
|
+
conditions << tags_condition(filter)
|
33
|
+
|
34
|
+
query = base_query(conditions)
|
35
|
+
query = paginated_query query, per: per, page: page
|
36
|
+
query
|
37
|
+
end
|
38
|
+
|
39
|
+
def paginated_query(query, per:, page:)
|
40
|
+
expect! per => Integer
|
41
|
+
expect! page => Integer
|
42
|
+
|
43
|
+
<<~SQL
|
44
|
+
SELECT * FROM (#{query}) sq LIMIT #{per} OFFSET #{per * page}
|
45
|
+
SQL
|
46
|
+
end
|
47
|
+
|
48
|
+
def base_query(conditions = [])
|
49
|
+
conditions.compact!
|
50
|
+
conditions << "TRUE"
|
51
|
+
condition_fragment = conditions
|
52
|
+
.compact
|
53
|
+
.map { |s| "(#{s})" }
|
54
|
+
.join(" AND ")
|
55
|
+
|
56
|
+
<<~SQL
|
57
|
+
SELECT
|
58
|
+
id,
|
59
|
+
full_id,
|
60
|
+
workflow || COALESCE('@' || workflow_version, '') || args AS job,
|
61
|
+
workflow_status,
|
62
|
+
status,
|
63
|
+
error,
|
64
|
+
COALESCE((results->0)::varchar, error_message) AS result,
|
65
|
+
next_run_at,
|
66
|
+
error_backtrace,
|
67
|
+
(now() at time zone 'utc') - created_at AS age,
|
68
|
+
updated_at - created_at AS runtime,
|
69
|
+
tags
|
70
|
+
FROM postjob.postjobs
|
71
|
+
WHERE #{condition_fragment}
|
72
|
+
ORDER BY id
|
73
|
+
SQL
|
74
|
+
end
|
75
|
+
|
76
|
+
def tags_condition(keys_and_values)
|
77
|
+
expect! keys_and_values => Hash
|
78
|
+
return nil if keys_and_values.empty?
|
79
|
+
|
80
|
+
"tags @> '#{Postjob::Queue::Encoder.encode(keys_and_values)}'"
|
81
|
+
end
|
82
|
+
end
|