gemstack-jobs 0.1.0 → 0.2.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +1 -1
- data/lib/gemstack/jobs/adapters/{postgres.rb → database.rb} +55 -33
- data/lib/gemstack/jobs/worker.rb +4 -4
- data/lib/gemstack/jobs.rb +11 -6
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0802dfa203bae021ece72db2d1a796c66e7a3b34fd157aa731d1812ccdda10c4'
|
|
4
|
+
data.tar.gz: 9e8c843b6018c6a33b594f739e1cb985a54f0ca54e0aafafd855e16a79e4629f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 65afed0b493a60301ecc39b8750d6b9ba812b511873ce76dc6a634768e60fec703ff91da8b5d476da8d960f40ed39424820db0e0a357c0f6d68e011fad2c5261
|
|
7
|
+
data.tar.gz: e7de052e354fd15863d5a3df6cf7aa64f45abba4fb1b08a13141862d141376026547a7874303556ab3f7a28b1e60db70512d4c2fcff86463d3d335f37f15eed8
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# gemstack-jobs
|
|
2
2
|
|
|
3
|
-
GemStack background jobs: a
|
|
3
|
+
GemStack background jobs: a queue in the app's database by default, swappable adapters.
|
|
4
4
|
|
|
5
5
|
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
6
6
|
applications. All GemStack gems are developed together in that repository and released with the same
|
|
@@ -9,31 +9,37 @@ module GemStack
|
|
|
9
9
|
SOURCE = <<~RUBY
|
|
10
10
|
# frozen_string_literal: true
|
|
11
11
|
|
|
12
|
-
# The GemStack job queue (docs/background-jobs.md)
|
|
13
|
-
#
|
|
12
|
+
# The GemStack job queue (docs/background-jobs.md), for PostgreSQL, MySQL and
|
|
13
|
+
# SQLite. Workers claim rows one at a time (FOR UPDATE SKIP LOCKED where the
|
|
14
|
+
# database has it); finished jobs are deleted, exhausted ones keep failed_at.
|
|
14
15
|
Sequel.migration do
|
|
15
|
-
|
|
16
|
+
up do
|
|
17
|
+
partial = database_type != :mysql # MySQL has no partial indexes
|
|
18
|
+
ready = partial ? { where: Sequel.lit("failed_at IS NULL AND locked_at IS NULL") } : {}
|
|
16
19
|
create_table(:gemstack_jobs) do
|
|
17
20
|
primary_key :id, type: :Bignum
|
|
18
21
|
String :queue, null: false, default: "default"
|
|
19
22
|
Integer :priority, null: false, default: 100
|
|
20
23
|
String :job_class, null: false
|
|
21
|
-
column :args, :jsonb, null: false
|
|
22
|
-
column :run_at, :timestamptz, null: false
|
|
24
|
+
column :args, :jsonb, null: false # json on MySQL and SQLite
|
|
25
|
+
column :run_at, :timestamptz, null: false
|
|
23
26
|
Integer :attempts, null: false, default: 0
|
|
24
27
|
String :last_error, text: true
|
|
25
28
|
column :locked_at, :timestamptz
|
|
26
29
|
String :locked_by
|
|
27
30
|
column :failed_at, :timestamptz
|
|
28
|
-
column :created_at, :timestamptz, null: false
|
|
31
|
+
column :created_at, :timestamptz, null: false
|
|
29
32
|
|
|
30
33
|
# The fetch query: ready jobs by queue, in priority/run_at order.
|
|
31
|
-
index %i[queue priority run_at id], name: :gemstack_jobs_ready,
|
|
32
|
-
|
|
33
|
-
index :
|
|
34
|
-
index :failed_at, name: :gemstack_jobs_failed, where: Sequel.lit("failed_at IS NOT NULL")
|
|
34
|
+
index %i[queue priority run_at id], name: :gemstack_jobs_ready, **ready
|
|
35
|
+
index :locked_at, name: :gemstack_jobs_locked
|
|
36
|
+
index :failed_at, name: :gemstack_jobs_failed
|
|
35
37
|
end
|
|
36
38
|
end
|
|
39
|
+
|
|
40
|
+
down do
|
|
41
|
+
drop_table(:gemstack_jobs)
|
|
42
|
+
end
|
|
37
43
|
end
|
|
38
44
|
RUBY
|
|
39
45
|
|
|
@@ -44,15 +50,19 @@ module GemStack
|
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
module Adapters
|
|
47
|
-
# The default adapter: jobs are rows in
|
|
53
|
+
# The default adapter: jobs are rows in the application's database
|
|
54
|
+
# (DECISIONS D-040, D-063) — PostgreSQL, MySQL or SQLite.
|
|
48
55
|
#
|
|
49
56
|
# - Enqueueing is an INSERT on the current connection, so inside
|
|
50
57
|
# GemStack.transaction a job exists only if the transaction commits.
|
|
51
|
-
# -
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
58
|
+
# - Workers claim one job at a time in a short transaction: with
|
|
59
|
+
# FOR UPDATE SKIP LOCKED on PostgreSQL and MySQL 8, so workers never
|
|
60
|
+
# block each other; on SQLite by taking the write lock up front.
|
|
61
|
+
# - PostgreSQL also NOTIFYs idle workers at once; elsewhere (and as a
|
|
62
|
+
# safety net) workers poll every config.jobs.poll_interval.
|
|
63
|
+
# - Times are set from Ruby, in UTC, so the database clock and time zone
|
|
64
|
+
# never matter.
|
|
65
|
+
class Database
|
|
56
66
|
CHANNEL = "gemstack_jobs"
|
|
57
67
|
|
|
58
68
|
def initialize(db: nil, table: Jobs.config.table)
|
|
@@ -68,25 +78,30 @@ module GemStack
|
|
|
68
78
|
end
|
|
69
79
|
|
|
70
80
|
def dataset = db[@table]
|
|
81
|
+
def postgres? = db.database_type == :postgres
|
|
71
82
|
|
|
72
83
|
def enqueue(payload)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
db.notify(CHANNEL, payload: payload["queue"])
|
|
84
|
+
now = Time.now
|
|
85
|
+
args = postgres? ? Sequel.pg_jsonb_wrap(payload["args"]) : JSON.generate(payload["args"])
|
|
86
|
+
id = dataset.insert(job_class: payload["job_class"], queue: payload["queue"], priority: payload["priority"],
|
|
87
|
+
args: args, run_at: payload["run_at"] || now, created_at: now)
|
|
88
|
+
db.notify(CHANNEL, payload: payload["queue"]) if postgres?
|
|
78
89
|
id
|
|
79
90
|
end
|
|
80
91
|
|
|
81
92
|
# Claims the next ready job for these queues ("*" = all). Returns a
|
|
82
93
|
# payload Hash or nil.
|
|
83
94
|
def claim(queues, worker)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
now = Time.now
|
|
96
|
+
db.transaction(**claim_options) do
|
|
97
|
+
ready = dataset.where(failed_at: nil, locked_at: nil).where { run_at <= now }
|
|
98
|
+
ready = ready.where(queue: queues) unless queues.include?("*")
|
|
99
|
+
ready = ready.order(:priority, :run_at, :id).limit(1)
|
|
100
|
+
ready = ready.for_update.skip_locked if ready.supports_skip_locked?
|
|
101
|
+
row = ready.select(*returned_columns).first
|
|
102
|
+
dataset.where(id: row[:id]).update(locked_at: now, locked_by: worker) if row
|
|
103
|
+
row && payload_for(row)
|
|
104
|
+
end
|
|
90
105
|
end
|
|
91
106
|
|
|
92
107
|
def complete(id) = dataset.where(id: id).delete
|
|
@@ -99,7 +114,7 @@ module GemStack
|
|
|
99
114
|
def fail(id, attempts:, error:)
|
|
100
115
|
return complete(id) unless Jobs.config.keep_failed
|
|
101
116
|
|
|
102
|
-
dataset.where(id: id).update(locked_at: nil, locked_by: nil, failed_at:
|
|
117
|
+
dataset.where(id: id).update(locked_at: nil, locked_by: nil, failed_at: Time.now,
|
|
103
118
|
attempts: attempts, last_error: describe(error))
|
|
104
119
|
end
|
|
105
120
|
|
|
@@ -108,7 +123,7 @@ module GemStack
|
|
|
108
123
|
|
|
109
124
|
# Releases jobs locked longer than `timeout` seconds (their worker died).
|
|
110
125
|
def release_stale(timeout)
|
|
111
|
-
cutoff =
|
|
126
|
+
cutoff = Time.now - Float(timeout)
|
|
112
127
|
dataset.where { locked_at < cutoff }.update(locked_at: nil, locked_by: nil)
|
|
113
128
|
end
|
|
114
129
|
|
|
@@ -116,7 +131,7 @@ module GemStack
|
|
|
116
131
|
def retry_failed(ids = nil)
|
|
117
132
|
failed = dataset.exclude(failed_at: nil)
|
|
118
133
|
failed = failed.where(id: ids) if ids
|
|
119
|
-
failed.update(failed_at: nil, attempts: 0, run_at:
|
|
134
|
+
failed.update(failed_at: nil, attempts: 0, run_at: Time.now, last_error: nil)
|
|
120
135
|
end
|
|
121
136
|
|
|
122
137
|
def discard_failed(ids = nil)
|
|
@@ -132,7 +147,7 @@ module GemStack
|
|
|
132
147
|
|
|
133
148
|
# { "default" => { ready:, scheduled:, running:, failed: }, ... }
|
|
134
149
|
def stats
|
|
135
|
-
now =
|
|
150
|
+
now = Time.now
|
|
136
151
|
states = Sequel.case(
|
|
137
152
|
[[Sequel.~(failed_at: nil), "failed"], [Sequel.~(locked_at: nil), "running"],
|
|
138
153
|
[Sequel[:run_at] > now, "scheduled"]], "ready"
|
|
@@ -145,22 +160,29 @@ module GemStack
|
|
|
145
160
|
|
|
146
161
|
private
|
|
147
162
|
|
|
163
|
+
# SQLite: take the write lock when the transaction starts, so two
|
|
164
|
+
# workers can't both read the same ready row.
|
|
165
|
+
def claim_options = db.database_type == :sqlite ? { mode: :immediate } : {}
|
|
166
|
+
|
|
148
167
|
# args as text, parsed with the json gem: jobs receive plain Hash/Array
|
|
149
168
|
# values rather than Sequel's JSONB wrappers. (Lazy: Sequel may not be loaded.)
|
|
150
169
|
def returned_columns
|
|
151
170
|
@returned_columns ||= [:id, :job_class, :queue, :priority, :attempts,
|
|
152
|
-
Sequel.cast(:args, :text).as(:args_json)]
|
|
171
|
+
postgres? ? Sequel.cast(:args, :text).as(:args_json) : Sequel.as(:args, :args_json)]
|
|
153
172
|
end
|
|
154
173
|
|
|
155
174
|
def payload_for(row)
|
|
156
175
|
{ "id" => row[:id], "job_class" => row[:job_class], "queue" => row[:queue],
|
|
157
|
-
"priority" => row[:priority], "args" => JSON.parse(row[:args_json]), "attempts" => row[:attempts] }
|
|
176
|
+
"priority" => row[:priority], "args" => JSON.parse(row[:args_json].to_s), "attempts" => row[:attempts] }
|
|
158
177
|
end
|
|
159
178
|
|
|
160
179
|
def describe(error)
|
|
161
180
|
"#{error.class}: #{error.message}\n#{Array(error.backtrace).first(20).join("\n")}"[0, 10_000]
|
|
162
181
|
end
|
|
163
182
|
end
|
|
183
|
+
|
|
184
|
+
# The adapter's name before it supported MySQL and SQLite; `adapter: :postgres` still works.
|
|
185
|
+
Postgres = Database
|
|
164
186
|
end
|
|
165
187
|
end
|
|
166
188
|
end
|
data/lib/gemstack/jobs/worker.rb
CHANGED
|
@@ -18,13 +18,13 @@ module GemStack
|
|
|
18
18
|
class Worker
|
|
19
19
|
attr_reader :id
|
|
20
20
|
|
|
21
|
-
def initialize(store: Adapters::
|
|
21
|
+
def initialize(store: Adapters::Database.new, queues: Jobs.config.queues, concurrency: Jobs.config.concurrency,
|
|
22
22
|
poll_interval: Jobs.config.poll_interval, lock_timeout: Jobs.config.lock_timeout,
|
|
23
23
|
shutdown_timeout: Jobs.config.shutdown_timeout)
|
|
24
24
|
@store = store
|
|
25
25
|
@queues = Array(queues).map(&:to_s)
|
|
26
26
|
@concurrency = concurrency
|
|
27
|
-
@poll_interval = poll_interval
|
|
27
|
+
@poll_interval = poll_interval || (store.respond_to?(:postgres?) && store.postgres? ? 5 : 1)
|
|
28
28
|
@lock_timeout = lock_timeout
|
|
29
29
|
@shutdown_timeout = shutdown_timeout
|
|
30
30
|
@id = "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(3)}"
|
|
@@ -41,7 +41,7 @@ module GemStack
|
|
|
41
41
|
@running = true
|
|
42
42
|
GemStack.logger.info("jobs worker started", worker: @id, queues: @queues.join(","), concurrency: @concurrency)
|
|
43
43
|
@threads = Array.new(@concurrency) { |i| Thread.new { work_loop(i) } }
|
|
44
|
-
@listener = Thread.new { listen_loop }
|
|
44
|
+
@listener = Thread.new { listen_loop } if @store.respond_to?(:postgres?) && @store.postgres?
|
|
45
45
|
@reaper = Thread.new { reap_loop }
|
|
46
46
|
self
|
|
47
47
|
end
|
|
@@ -112,7 +112,7 @@ module GemStack
|
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
def listen_loop
|
|
115
|
-
@store.db.listen(Adapters::
|
|
115
|
+
@store.db.listen(Adapters::Database::CHANNEL, loop: ->(_conn) { throw :stop unless @running },
|
|
116
116
|
timeout: @poll_interval) { wake }
|
|
117
117
|
rescue Sequel::DatabaseConnectionError => e
|
|
118
118
|
GemStack.logger.warn("jobs worker: LISTEN failed, falling back to polling", error: e.message)
|
data/lib/gemstack/jobs.rb
CHANGED
|
@@ -20,12 +20,13 @@ module GemStack
|
|
|
20
20
|
# Delivery is at-least-once: make perform idempotent.
|
|
21
21
|
module Jobs
|
|
22
22
|
class Config < Settings
|
|
23
|
-
# :
|
|
23
|
+
# :database (default with gemstack-db: the app's PostgreSQL, MySQL or
|
|
24
|
+
# SQLite; :postgres is an alias), :async (in-process threads),
|
|
24
25
|
# :inline (run immediately), :test (record only; default in tests),
|
|
25
26
|
# :sidekiq, or an adapter object responding to #enqueue(payload).
|
|
26
27
|
setting :adapter, default: lambda {
|
|
27
28
|
if GemStack.env.test? then :test
|
|
28
|
-
elsif defined?(GemStack::DB) then :
|
|
29
|
+
elsif defined?(GemStack::DB) then :database
|
|
29
30
|
else :async
|
|
30
31
|
end
|
|
31
32
|
}
|
|
@@ -35,8 +36,9 @@ module GemStack
|
|
|
35
36
|
# Worker settings (`gemstack jobs`).
|
|
36
37
|
setting :queues, default: -> { ENV.fetch("GEMSTACK_JOB_QUEUES", "*").split(",").map(&:strip) }
|
|
37
38
|
setting :concurrency, default: -> { Integer(ENV.fetch("GEMSTACK_JOB_CONCURRENCY", 5)) }
|
|
38
|
-
# Seconds between polls
|
|
39
|
-
|
|
39
|
+
# Seconds between polls. PostgreSQL NOTIFY wakes workers instantly, so
|
|
40
|
+
# there polling is only a safety net (5 s); MySQL and SQLite rely on it (1 s).
|
|
41
|
+
setting :poll_interval, default: nil
|
|
40
42
|
# A job locked longer than this is assumed abandoned (worker crashed) and is released.
|
|
41
43
|
# Jobs that legitimately run longer must raise it.
|
|
42
44
|
setting :lock_timeout, default: 30 * 60
|
|
@@ -72,7 +74,7 @@ module GemStack
|
|
|
72
74
|
|
|
73
75
|
def build_adapter(setting)
|
|
74
76
|
case setting
|
|
75
|
-
when :postgres, "postgres" then Adapters::
|
|
77
|
+
when :database, "database", :postgres, "postgres" then Adapters::Database.new
|
|
76
78
|
when :async, "async" then Adapters::Async.new
|
|
77
79
|
when :inline, "inline" then Adapters::Inline.new
|
|
78
80
|
when :test, "test" then Adapters::Test.new
|
|
@@ -84,6 +86,9 @@ module GemStack
|
|
|
84
86
|
end
|
|
85
87
|
end
|
|
86
88
|
|
|
89
|
+
# True when jobs are rows in the application's database (and need a worker).
|
|
90
|
+
def database_queue?(setting = config.adapter) = %w[database postgres].include?(setting.to_s)
|
|
91
|
+
|
|
87
92
|
# Instrumentation for metrics/monitoring:
|
|
88
93
|
# GemStack::Jobs.subscribe(:failed) { |event| Sentry.capture_message(...) }
|
|
89
94
|
# Events: :enqueued, :performed, :retried, :failed, :discarded.
|
|
@@ -133,7 +138,7 @@ require_relative "jobs/executor"
|
|
|
133
138
|
require_relative "jobs/adapters/inline"
|
|
134
139
|
require_relative "jobs/adapters/test"
|
|
135
140
|
require_relative "jobs/adapters/async"
|
|
136
|
-
require_relative "jobs/adapters/
|
|
141
|
+
require_relative "jobs/adapters/database"
|
|
137
142
|
require_relative "jobs/adapters/sidekiq"
|
|
138
143
|
|
|
139
144
|
GemStack::Config.namespace(:jobs, GemStack::Jobs::Config)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gemstack-jobs
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shoaib Malik
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - '='
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.
|
|
18
|
+
version: 0.2.0
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - '='
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.
|
|
25
|
+
version: 0.2.0
|
|
26
26
|
email:
|
|
27
27
|
- gemstack26@gmail.com
|
|
28
28
|
executables: []
|
|
@@ -35,8 +35,8 @@ files:
|
|
|
35
35
|
- lib/gemstack/job.rb
|
|
36
36
|
- lib/gemstack/jobs.rb
|
|
37
37
|
- lib/gemstack/jobs/adapters/async.rb
|
|
38
|
+
- lib/gemstack/jobs/adapters/database.rb
|
|
38
39
|
- lib/gemstack/jobs/adapters/inline.rb
|
|
39
|
-
- lib/gemstack/jobs/adapters/postgres.rb
|
|
40
40
|
- lib/gemstack/jobs/adapters/sidekiq.rb
|
|
41
41
|
- lib/gemstack/jobs/adapters/test.rb
|
|
42
42
|
- lib/gemstack/jobs/executor.rb
|
|
@@ -67,5 +67,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
67
67
|
requirements: []
|
|
68
68
|
rubygems_version: 4.0.20
|
|
69
69
|
specification_version: 4
|
|
70
|
-
summary: 'GemStack background jobs: a
|
|
70
|
+
summary: 'GemStack background jobs: a queue in the app''s database by default, swappable
|
|
71
|
+
adapters'
|
|
71
72
|
test_files: []
|