gemstack-jobs 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +28 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/postgres.rb +166 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +139 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f76474daac16429de13f512235ca7eb314b8981ea0f700fedba334b5f8242efa
|
|
4
|
+
data.tar.gz: 2791f654094f13f0e0c7745106f5bff32b0027be4a69e7c166c9a44c0ef31fcb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d7ccc72bc94799b9883013b5a1a2761dc9757f77290604a0aa210cdac0a3d62dd829564a38a1fa4a556451aa33202644dcb6255477612debfe4e36607a9c86e4
|
|
7
|
+
data.tar.gz: a3a95da5e5ef21f12d292b38228e25ff9a9a561ef42179c155dfbe61b17c000421852b3d7970f705bfbcd7055681b96d619bf384020fab2f0b52f73bae05f99e
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoaib Malik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# gemstack-jobs
|
|
2
|
+
|
|
3
|
+
GemStack background jobs: a PostgreSQL queue by default, swappable adapters.
|
|
4
|
+
|
|
5
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
6
|
+
applications. All GemStack gems are developed together in that repository and released with the same
|
|
7
|
+
version.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Optional module — added by `gemstack new`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "gemstack-jobs", "~> 0.1"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Documentation
|
|
18
|
+
|
|
19
|
+
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/background-jobs.md)
|
|
20
|
+
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
21
|
+
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
22
|
+
|
|
23
|
+
Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
|
|
24
|
+
(this gem lives in `gems/gemstack-jobs`).
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
data/lib/gemstack/job.rb
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Base class for background jobs. See GemStack::Jobs.
|
|
5
|
+
#
|
|
6
|
+
# class ImportProducts < GemStack::Job
|
|
7
|
+
# queue :imports
|
|
8
|
+
# priority 10 # lower runs first (default 100)
|
|
9
|
+
# retry_on Faraday::TimeoutError, attempts: 5, wait: :exponential
|
|
10
|
+
# discard_on GemStack::NotFound # the record is gone; nothing to do
|
|
11
|
+
#
|
|
12
|
+
# def perform(import_id, options = {})
|
|
13
|
+
# ...
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# ImportProducts.perform_later(import.id) # => job id
|
|
18
|
+
# ImportProducts.set(wait: 300, queue: "slow").perform_later(1) # in 5 minutes
|
|
19
|
+
# ImportProducts.set(at: Time.now + 3600).perform_later(1)
|
|
20
|
+
# ImportProducts.perform_now(1) # synchronously
|
|
21
|
+
#
|
|
22
|
+
# Arguments must be JSON values (strings, numbers, booleans, nil, arrays,
|
|
23
|
+
# hashes with string or symbol keys). Pass ids, not records. Hash keys come
|
|
24
|
+
# back as strings.
|
|
25
|
+
class Job
|
|
26
|
+
RetryRule = Struct.new(:classes, :attempts, :wait)
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def queue(name = nil)
|
|
30
|
+
@queue = name.to_s if name
|
|
31
|
+
@queue || (superclass.respond_to?(:queue) ? superclass.queue : Jobs.config.default_queue)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def priority(value = nil)
|
|
35
|
+
@priority = Integer(value) if value
|
|
36
|
+
@priority || (superclass.respond_to?(:priority) ? superclass.priority : Jobs.config.default_priority)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# retry_on Net::ReadTimeout, attempts: 5, wait: 30 # fixed seconds
|
|
40
|
+
# retry_on StandardError, wait: :exponential # default
|
|
41
|
+
# retry_on Api::RateLimited, wait: ->(attempt) { attempt * 60 }
|
|
42
|
+
# The first matching rule (most recently declared first) decides.
|
|
43
|
+
def retry_on(*classes, attempts: nil, wait: :exponential)
|
|
44
|
+
retry_rules.unshift(RetryRule.new(classes, attempts, wait))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Errors that mean the job should simply be dropped (logged, not retried).
|
|
48
|
+
def discard_on(*classes)
|
|
49
|
+
discard_classes.concat(classes)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def retry_rules = @retry_rules ||= superclass.respond_to?(:retry_rules) ? superclass.retry_rules.dup : []
|
|
53
|
+
|
|
54
|
+
def discard_classes
|
|
55
|
+
@discard_classes ||= superclass.respond_to?(:discard_classes) ? superclass.discard_classes.dup : []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def perform_later(*) = Enqueuer.new(self).perform_later(*)
|
|
59
|
+
def set(**) = Enqueuer.new(self, **)
|
|
60
|
+
|
|
61
|
+
def perform_now(*args)
|
|
62
|
+
new.perform(*Jobs::Arguments.load(Jobs::Arguments.dump(args)))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# [max attempts, seconds to wait before the next attempt] for an error
|
|
66
|
+
# raised on attempt number `attempt` (1-based); nil wait = give up.
|
|
67
|
+
def retry_decision(error, attempt)
|
|
68
|
+
rule = retry_rules.find { |r| r.classes.any? { |klass| error.is_a?(klass) } }
|
|
69
|
+
max = rule&.attempts || Jobs.config.default_max_attempts
|
|
70
|
+
return [max, nil] if attempt >= max
|
|
71
|
+
|
|
72
|
+
[max, backoff(rule&.wait || :exponential, attempt)]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def discard?(error) = discard_classes.any? { |klass| error.is_a?(klass) }
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Sidekiq's curve: 16s, 31s, 96s, 271s, … ≈ 4 hours over 10 attempts.
|
|
80
|
+
def backoff(wait, attempt)
|
|
81
|
+
case wait
|
|
82
|
+
when :exponential then (attempt**4) + 15 + (rand(10) * attempt)
|
|
83
|
+
when Proc then Float(wait.call(attempt))
|
|
84
|
+
else Float(wait)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Builds the payload for a (possibly customised) enqueue.
|
|
90
|
+
class Enqueuer
|
|
91
|
+
def initialize(job_class, wait: nil, at: nil, queue: nil, priority: nil)
|
|
92
|
+
@job_class = job_class
|
|
93
|
+
@run_at = at || (wait && (Time.now + Float(wait)))
|
|
94
|
+
@queue = queue&.to_s
|
|
95
|
+
@priority = priority
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def perform_later(*args)
|
|
99
|
+
payload = {
|
|
100
|
+
"job_class" => @job_class.name,
|
|
101
|
+
"queue" => @queue || @job_class.queue,
|
|
102
|
+
"priority" => @priority || @job_class.priority,
|
|
103
|
+
"args" => Jobs::Arguments.dump(args),
|
|
104
|
+
"run_at" => @run_at
|
|
105
|
+
}
|
|
106
|
+
raise ArgumentError, "anonymous job classes can't be enqueued" unless payload["job_class"]
|
|
107
|
+
|
|
108
|
+
id = Jobs.adapter.enqueue(payload)
|
|
109
|
+
Jobs.instrument(:enqueued, job_class: payload["job_class"], job_id: id, queue: payload["queue"],
|
|
110
|
+
run_at: payload["run_at"])
|
|
111
|
+
id
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Subclasses implement this.
|
|
116
|
+
def perform(*)
|
|
117
|
+
raise NotImplementedError, "#{self.class.name}#perform is not implemented"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Set by the executor for the running job.
|
|
121
|
+
attr_accessor :job_id, :attempt
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
module Jobs
|
|
125
|
+
# Validates and converts arguments to/from their JSON form.
|
|
126
|
+
module Arguments
|
|
127
|
+
module_function
|
|
128
|
+
|
|
129
|
+
def dump(args) = args.map { |arg| dump_value(arg, "argument") }
|
|
130
|
+
|
|
131
|
+
def load(json) = json
|
|
132
|
+
|
|
133
|
+
def dump_value(value, path)
|
|
134
|
+
case value
|
|
135
|
+
when String, Integer, true, false, nil then value
|
|
136
|
+
when Float then value.finite? ? value : invalid(value, path)
|
|
137
|
+
when Symbol then value.name
|
|
138
|
+
when Array then value.each_with_index.map { |v, i| dump_value(v, "#{path}[#{i}]") }
|
|
139
|
+
when Hash
|
|
140
|
+
value.to_h do |key, v|
|
|
141
|
+
invalid(key, "#{path} key") unless key.is_a?(String) || key.is_a?(Symbol)
|
|
142
|
+
[key.to_s, dump_value(v, "#{path}[#{key.inspect}]")]
|
|
143
|
+
end
|
|
144
|
+
else invalid(value, path)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def invalid(value, path)
|
|
149
|
+
hint = value.respond_to?(:pk) || value.respond_to?(:id) ? " — pass its id instead" : ""
|
|
150
|
+
raise SerializationError, "#{path} #{value.class} can't be serialized to JSON#{hint}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Jobs
|
|
5
|
+
module Adapters
|
|
6
|
+
# An in-process thread pool with an in-memory schedule (retries and
|
|
7
|
+
# `set(wait:)` work). Jobs are lost when the process exits, so it is
|
|
8
|
+
# meant for development and apps without a database — use :postgres
|
|
9
|
+
# or :sidekiq in production.
|
|
10
|
+
class Async
|
|
11
|
+
include AfterCommit
|
|
12
|
+
|
|
13
|
+
Entry = Struct.new(:run_at, :sequence, :payload)
|
|
14
|
+
|
|
15
|
+
def initialize(concurrency: Jobs.config.concurrency)
|
|
16
|
+
@concurrency = concurrency
|
|
17
|
+
@entries = []
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@available = ConditionVariable.new
|
|
20
|
+
@sequence = 0
|
|
21
|
+
@running = 0
|
|
22
|
+
@threads = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enqueue(payload)
|
|
26
|
+
id = SecureRandom.uuid
|
|
27
|
+
after_commit { schedule(payload.merge("id" => id, "attempts" => 0), payload["run_at"] || Time.now) }
|
|
28
|
+
id
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Blocks until no jobs are queued or running (mainly for tests).
|
|
32
|
+
def drain(timeout: 10)
|
|
33
|
+
deadline = monotonic + timeout
|
|
34
|
+
sleep 0.01 until @mutex.synchronize { @entries.empty? && @running.zero? } || monotonic > deadline
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def shutdown
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@stopping = true
|
|
40
|
+
@available.broadcast
|
|
41
|
+
end
|
|
42
|
+
@threads&.each { |thread| thread.join(Jobs.config.shutdown_timeout) }
|
|
43
|
+
@threads = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def schedule(payload, run_at)
|
|
49
|
+
worker_threads
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@sequence += 1
|
|
52
|
+
@entries << Entry.new(run_at, @sequence, payload)
|
|
53
|
+
@entries.sort_by! { |e| [e.run_at, e.payload["priority"] || 100, e.sequence] }
|
|
54
|
+
@available.broadcast
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Started lazily on the first job; shutdown joins them.
|
|
59
|
+
def worker_threads
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
@threads ||= Array.new(@concurrency) { Thread.new { work } } # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def work
|
|
66
|
+
loop do
|
|
67
|
+
entry = next_entry or break
|
|
68
|
+
outcome = Executor.execute(entry.payload)
|
|
69
|
+
schedule(entry.payload.merge("attempts" => outcome.attempt), outcome.run_at) if outcome.status == :retry
|
|
70
|
+
ensure
|
|
71
|
+
@mutex.synchronize { @running -= 1 } if entry
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def next_entry
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
loop do
|
|
78
|
+
return nil if @stopping
|
|
79
|
+
|
|
80
|
+
first = @entries.first
|
|
81
|
+
if first && first.run_at <= Time.now
|
|
82
|
+
@running += 1
|
|
83
|
+
return @entries.shift
|
|
84
|
+
end
|
|
85
|
+
@available.wait(@mutex, first ? [first.run_at - Time.now, 0.01].max : nil)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Jobs
|
|
5
|
+
module Adapters
|
|
6
|
+
# Runs jobs immediately, in the caller's thread, ignoring schedules and
|
|
7
|
+
# retries. Errors propagate, so nothing is silently swallowed. Handy for
|
|
8
|
+
# scripts and debugging.
|
|
9
|
+
class Inline
|
|
10
|
+
def enqueue(payload)
|
|
11
|
+
id = SecureRandom.uuid
|
|
12
|
+
job = Executor.resolve(payload["job_class"]).new
|
|
13
|
+
job.job_id = id
|
|
14
|
+
job.attempt = 1
|
|
15
|
+
job.perform(*Arguments.load(payload["args"]))
|
|
16
|
+
id
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Defers a block until the surrounding database transaction commits
|
|
21
|
+
# (when gemstack-db is in use), so a job never runs before — or without —
|
|
22
|
+
# the data it depends on. The :postgres adapter gets this for free by
|
|
23
|
+
# inserting into the same transaction.
|
|
24
|
+
module AfterCommit
|
|
25
|
+
def after_commit(&)
|
|
26
|
+
db = defined?(GemStack::DB) && GemStack::DB.connected? ? GemStack::DB.connection : nil
|
|
27
|
+
return yield unless db&.in_transaction?
|
|
28
|
+
|
|
29
|
+
db.after_commit(&)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Jobs
|
|
5
|
+
# The migration new apps get (and `gemstack jobs:install` writes). Kept
|
|
6
|
+
# as source text so the app owns a plain, readable migration while tests
|
|
7
|
+
# and the generator share one definition.
|
|
8
|
+
module Migration
|
|
9
|
+
SOURCE = <<~RUBY
|
|
10
|
+
# frozen_string_literal: true
|
|
11
|
+
|
|
12
|
+
# The GemStack job queue (docs/background-jobs.md). Workers claim rows with
|
|
13
|
+
# FOR UPDATE SKIP LOCKED; finished jobs are deleted, exhausted ones keep failed_at.
|
|
14
|
+
Sequel.migration do
|
|
15
|
+
change do
|
|
16
|
+
create_table(:gemstack_jobs) do
|
|
17
|
+
primary_key :id, type: :Bignum
|
|
18
|
+
String :queue, null: false, default: "default"
|
|
19
|
+
Integer :priority, null: false, default: 100
|
|
20
|
+
String :job_class, null: false
|
|
21
|
+
column :args, :jsonb, null: false, default: Sequel.lit("'[]'::jsonb")
|
|
22
|
+
column :run_at, :timestamptz, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
23
|
+
Integer :attempts, null: false, default: 0
|
|
24
|
+
String :last_error, text: true
|
|
25
|
+
column :locked_at, :timestamptz
|
|
26
|
+
String :locked_by
|
|
27
|
+
column :failed_at, :timestamptz
|
|
28
|
+
column :created_at, :timestamptz, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
29
|
+
|
|
30
|
+
# 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
|
+
where: Sequel.lit("failed_at IS NULL AND locked_at IS NULL")
|
|
33
|
+
index :locked_at, name: :gemstack_jobs_locked, where: Sequel.lit("locked_at IS NOT NULL")
|
|
34
|
+
index :failed_at, name: :gemstack_jobs_failed, where: Sequel.lit("failed_at IS NOT NULL")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
RUBY
|
|
39
|
+
|
|
40
|
+
def self.apply(db, direction = :up)
|
|
41
|
+
Sequel.extension :migration
|
|
42
|
+
eval(SOURCE, TOPLEVEL_BINDING, "gemstack_jobs_migration.rb").apply(db, direction) # rubocop:disable Security/Eval
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module Adapters
|
|
47
|
+
# The default adapter: jobs are rows in PostgreSQL (DECISIONS D-040).
|
|
48
|
+
#
|
|
49
|
+
# - Enqueueing is an INSERT on the current connection, so inside
|
|
50
|
+
# GemStack.transaction a job exists only if the transaction commits.
|
|
51
|
+
# - NOTIFY (also transactional) wakes idle workers immediately; polling
|
|
52
|
+
# every config.jobs.poll_interval is the safety net.
|
|
53
|
+
# - Workers claim one job at a time with FOR UPDATE SKIP LOCKED, so
|
|
54
|
+
# workers never block each other or run the same job twice at once.
|
|
55
|
+
class Postgres
|
|
56
|
+
CHANNEL = "gemstack_jobs"
|
|
57
|
+
|
|
58
|
+
def initialize(db: nil, table: Jobs.config.table)
|
|
59
|
+
@db = db
|
|
60
|
+
@table = table
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def db
|
|
64
|
+
@db || begin
|
|
65
|
+
require "gemstack/db"
|
|
66
|
+
GemStack::DB.connection
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dataset = db[@table]
|
|
71
|
+
|
|
72
|
+
def enqueue(payload)
|
|
73
|
+
id = dataset.insert(
|
|
74
|
+
job_class: payload["job_class"], queue: payload["queue"], priority: payload["priority"],
|
|
75
|
+
args: Sequel.pg_jsonb_wrap(payload["args"]), run_at: payload["run_at"] || Sequel::CURRENT_TIMESTAMP
|
|
76
|
+
)
|
|
77
|
+
db.notify(CHANNEL, payload: payload["queue"])
|
|
78
|
+
id
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Claims the next ready job for these queues ("*" = all). Returns a
|
|
82
|
+
# payload Hash or nil.
|
|
83
|
+
def claim(queues, worker)
|
|
84
|
+
ready = dataset.where(failed_at: nil, locked_at: nil).where { run_at <= Sequel::CURRENT_TIMESTAMP }
|
|
85
|
+
ready = ready.where(queue: queues) unless queues.include?("*")
|
|
86
|
+
next_id = ready.order(:priority, :run_at, :id).limit(1).for_update.skip_locked.select(:id)
|
|
87
|
+
row = dataset.where(id: next_id).returning(*returned_columns)
|
|
88
|
+
.update(locked_at: Sequel::CURRENT_TIMESTAMP, locked_by: worker).first
|
|
89
|
+
row && payload_for(row)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def complete(id) = dataset.where(id: id).delete
|
|
93
|
+
|
|
94
|
+
def reschedule(id, run_at:, attempts:, error:)
|
|
95
|
+
dataset.where(id: id).update(locked_at: nil, locked_by: nil, run_at: run_at, attempts: attempts,
|
|
96
|
+
last_error: describe(error))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def fail(id, attempts:, error:)
|
|
100
|
+
return complete(id) unless Jobs.config.keep_failed
|
|
101
|
+
|
|
102
|
+
dataset.where(id: id).update(locked_at: nil, locked_by: nil, failed_at: Sequel::CURRENT_TIMESTAMP,
|
|
103
|
+
attempts: attempts, last_error: describe(error))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Releases this worker's claimed jobs (graceful shutdown).
|
|
107
|
+
def release(worker) = dataset.where(locked_by: worker).update(locked_at: nil, locked_by: nil)
|
|
108
|
+
|
|
109
|
+
# Releases jobs locked longer than `timeout` seconds (their worker died).
|
|
110
|
+
def release_stale(timeout)
|
|
111
|
+
cutoff = Sequel.lit("CURRENT_TIMESTAMP - make_interval(secs => ?)", Float(timeout))
|
|
112
|
+
dataset.where { locked_at < cutoff }.update(locked_at: nil, locked_by: nil)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Failed jobs back to the queue: all, or the given ids.
|
|
116
|
+
def retry_failed(ids = nil)
|
|
117
|
+
failed = dataset.exclude(failed_at: nil)
|
|
118
|
+
failed = failed.where(id: ids) if ids
|
|
119
|
+
failed.update(failed_at: nil, attempts: 0, run_at: Sequel::CURRENT_TIMESTAMP, last_error: nil)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def discard_failed(ids = nil)
|
|
123
|
+
failed = dataset.exclude(failed_at: nil)
|
|
124
|
+
failed = failed.where(id: ids) if ids
|
|
125
|
+
failed.delete
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def failed(limit: 20)
|
|
129
|
+
dataset.exclude(failed_at: nil).order(Sequel.desc(:failed_at)).limit(limit)
|
|
130
|
+
.select(:id, :queue, :job_class, :attempts, :failed_at, :last_error).all
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# { "default" => { ready:, scheduled:, running:, failed: }, ... }
|
|
134
|
+
def stats
|
|
135
|
+
now = Sequel::CURRENT_TIMESTAMP
|
|
136
|
+
states = Sequel.case(
|
|
137
|
+
[[Sequel.~(failed_at: nil), "failed"], [Sequel.~(locked_at: nil), "running"],
|
|
138
|
+
[Sequel[:run_at] > now, "scheduled"]], "ready"
|
|
139
|
+
)
|
|
140
|
+
dataset.group_and_count(:queue, states.as(:state)).all.each_with_object({}) do |row, result|
|
|
141
|
+
(result[row[:queue]] ||= { ready: 0, scheduled: 0, running: 0, failed: 0 })[row[:state].to_sym] =
|
|
142
|
+
row[:count]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# args as text, parsed with the json gem: jobs receive plain Hash/Array
|
|
149
|
+
# values rather than Sequel's JSONB wrappers. (Lazy: Sequel may not be loaded.)
|
|
150
|
+
def returned_columns
|
|
151
|
+
@returned_columns ||= [:id, :job_class, :queue, :priority, :attempts,
|
|
152
|
+
Sequel.cast(:args, :text).as(:args_json)].freeze
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def payload_for(row)
|
|
156
|
+
{ "id" => row[:id], "job_class" => row[:job_class], "queue" => row[:queue],
|
|
157
|
+
"priority" => row[:priority], "args" => JSON.parse(row[:args_json]), "attempts" => row[:attempts] }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def describe(error)
|
|
161
|
+
"#{error.class}: #{error.message}\n#{Array(error.backtrace).first(20).join("\n")}"[0, 10_000]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Jobs
|
|
5
|
+
module Adapters
|
|
6
|
+
# Runs GemStack jobs on Sidekiq (for teams already running Redis +
|
|
7
|
+
# Sidekiq). Add `gem "sidekiq"`, set `config.jobs.adapter = :sidekiq`,
|
|
8
|
+
# and run `bundle exec sidekiq -r ./config/sidekiq.rb` (see docs/background-jobs.md).
|
|
9
|
+
#
|
|
10
|
+
# GemStack's executor still decides retries and discards, so job
|
|
11
|
+
# classes behave identically on every adapter; Sidekiq's own retries
|
|
12
|
+
# are off and exhausted jobs go to Sidekiq's Dead set.
|
|
13
|
+
class Sidekiq
|
|
14
|
+
include AfterCommit
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
require "sidekiq"
|
|
18
|
+
Runner.define!
|
|
19
|
+
rescue LoadError
|
|
20
|
+
raise ConfigurationError, 'the :sidekiq job adapter needs `gem "sidekiq"` in the Gemfile'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enqueue(payload)
|
|
24
|
+
jid = SecureRandom.hex(12)
|
|
25
|
+
after_commit { Runner.push(payload.merge("attempts" => 0), jid: jid) }
|
|
26
|
+
jid
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The Sidekiq job class that executes GemStack payloads.
|
|
30
|
+
module Runner
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def define!
|
|
34
|
+
return if defined?(GemStack::Jobs::SidekiqRunner)
|
|
35
|
+
|
|
36
|
+
klass = Class.new do
|
|
37
|
+
include ::Sidekiq::Job
|
|
38
|
+
|
|
39
|
+
sidekiq_options retry: 0 # exhausted jobs → Dead set; GemStack schedules retries itself
|
|
40
|
+
|
|
41
|
+
def perform(payload)
|
|
42
|
+
payload = payload.merge("id" => jid)
|
|
43
|
+
outcome = Executor.execute(payload)
|
|
44
|
+
case outcome.status
|
|
45
|
+
when :retry then Runner.push(payload.merge("attempts" => outcome.attempt), at: outcome.run_at)
|
|
46
|
+
when :failed then raise outcome.error
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
GemStack::Jobs.const_set(:SidekiqRunner, klass)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def push(payload, at: nil, jid: nil)
|
|
54
|
+
item = { "class" => GemStack::Jobs::SidekiqRunner, "queue" => payload["queue"],
|
|
55
|
+
"args" => [payload.except("run_at", "id")] }
|
|
56
|
+
item["jid"] = jid if jid
|
|
57
|
+
run_at = at || payload["run_at"]
|
|
58
|
+
item["at"] = run_at.to_f if run_at
|
|
59
|
+
::Sidekiq::Client.push(item)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Jobs
|
|
5
|
+
module Adapters
|
|
6
|
+
# Records jobs instead of running them (the default in tests). See
|
|
7
|
+
# GemStack::Jobs::Testing for assertions and perform_enqueued_jobs.
|
|
8
|
+
class Test
|
|
9
|
+
attr_reader :enqueued, :performed
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@enqueued = []
|
|
13
|
+
@performed = []
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@sequence = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def enqueue(payload)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
@sequence += 1
|
|
21
|
+
@enqueued << payload.merge("id" => @sequence, "attempts" => 0)
|
|
22
|
+
@sequence
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs enqueued jobs (and jobs they enqueue) until none are left, or
|
|
27
|
+
# only those matching `only`. An error a job raises is re-raised (unless
|
|
28
|
+
# the job discards it), so failing jobs fail the test. Returns the outcomes.
|
|
29
|
+
def perform_enqueued(only: nil, except_ids: [])
|
|
30
|
+
names = only && Array(only).map(&:to_s)
|
|
31
|
+
outcomes = []
|
|
32
|
+
loop do
|
|
33
|
+
payload = @mutex.synchronize do
|
|
34
|
+
index = @enqueued.index do |p|
|
|
35
|
+
(names.nil? || names.include?(p["job_class"])) && !except_ids.include?(p["id"])
|
|
36
|
+
end
|
|
37
|
+
index && @enqueued.delete_at(index)
|
|
38
|
+
end
|
|
39
|
+
break unless payload
|
|
40
|
+
|
|
41
|
+
outcome = Executor.execute(payload)
|
|
42
|
+
raise outcome.error if outcome.error && outcome.status != :discarded
|
|
43
|
+
|
|
44
|
+
@performed << payload
|
|
45
|
+
outcomes << outcome
|
|
46
|
+
end
|
|
47
|
+
outcomes
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clear
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@enqueued.clear
|
|
53
|
+
@performed.clear
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Jobs
|
|
5
|
+
# Runs one job payload and decides what happens next. Shared by every
|
|
6
|
+
# adapter, so retries, discards, failures and instrumentation behave the
|
|
7
|
+
# same everywhere.
|
|
8
|
+
#
|
|
9
|
+
# Returns an Outcome: :performed, :retry (with run_at), :discarded or :failed.
|
|
10
|
+
module Executor
|
|
11
|
+
Outcome = Struct.new(:status, :error, :run_at, :attempt, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# payload: "job_class", "args", "queue", "attempts" (runs so far), "id".
|
|
16
|
+
def execute(payload)
|
|
17
|
+
attempt = Integer(payload["attempts"] || 0) + 1
|
|
18
|
+
fields = { job_class: payload["job_class"], job_id: payload["id"], queue: payload["queue"], attempt: attempt }
|
|
19
|
+
job_class = resolve(payload["job_class"])
|
|
20
|
+
started = monotonic
|
|
21
|
+
run(job_class, payload, attempt)
|
|
22
|
+
Jobs.instrument(:performed, **fields, duration_ms: elapsed(started))
|
|
23
|
+
Outcome.new(status: :performed, attempt: attempt)
|
|
24
|
+
rescue UnknownJob => e
|
|
25
|
+
Jobs.instrument(:failed, **fields, error: e)
|
|
26
|
+
Outcome.new(status: :failed, error: e, attempt: attempt)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
failure(job_class, e, attempt, fields.merge(duration_ms: elapsed(started)))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(job_class, payload, attempt)
|
|
32
|
+
job = job_class.new
|
|
33
|
+
job.job_id = payload["id"]
|
|
34
|
+
job.attempt = attempt
|
|
35
|
+
job.perform(*Arguments.load(payload["args"] || []))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def failure(job_class, error, attempt, fields)
|
|
39
|
+
if job_class.discard?(error)
|
|
40
|
+
Jobs.instrument(:discarded, **fields, error: error)
|
|
41
|
+
return Outcome.new(status: :discarded, error: error, attempt: attempt)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
_max, wait = job_class.retry_decision(error, attempt)
|
|
45
|
+
if wait
|
|
46
|
+
run_at = Time.now + wait
|
|
47
|
+
Jobs.instrument(:retried, **fields, error: error, run_at: run_at)
|
|
48
|
+
Outcome.new(status: :retry, error: error, run_at: run_at, attempt: attempt)
|
|
49
|
+
else
|
|
50
|
+
Jobs.instrument(:failed, **fields, error: error)
|
|
51
|
+
Outcome.new(status: :failed, error: error, attempt: attempt)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Only GemStack::Job subclasses can run — a queue row naming another
|
|
56
|
+
# constant is never instantiated.
|
|
57
|
+
def resolve(name)
|
|
58
|
+
klass = Object.const_get(name.to_s)
|
|
59
|
+
raise UnknownJob, "#{name} is not a GemStack::Job" unless klass.is_a?(Class) && klass < Job
|
|
60
|
+
|
|
61
|
+
klass
|
|
62
|
+
rescue NameError
|
|
63
|
+
raise UnknownJob, "unknown job class #{name.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
def elapsed(started) = started && ((monotonic - started) * 1000).round(2)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gemstack/jobs"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Jobs
|
|
7
|
+
# Test helpers (included into GemStack::TestCase by the generated test helper):
|
|
8
|
+
#
|
|
9
|
+
# def test_signup_sends_welcome_email
|
|
10
|
+
# post_json "/api/signups", { email: "a@b.c" }
|
|
11
|
+
#
|
|
12
|
+
# assert_enqueued SendWelcomeEmail, args: [User.last.id]
|
|
13
|
+
# perform_enqueued_jobs
|
|
14
|
+
# assert_equal 1, Mailer.deliveries.size
|
|
15
|
+
# end
|
|
16
|
+
module Testing
|
|
17
|
+
def self.included(base)
|
|
18
|
+
base.class_eval do
|
|
19
|
+
def before_setup
|
|
20
|
+
super
|
|
21
|
+
GemStack::Jobs.adapter = GemStack::Jobs::Adapters::Test.new
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def enqueued_jobs = Jobs.adapter.enqueued
|
|
27
|
+
|
|
28
|
+
# Jobs of job_class (optionally with these args / on this queue) were enqueued.
|
|
29
|
+
def assert_enqueued(job_class, args: nil, queue: nil, count: nil)
|
|
30
|
+
matching = enqueued_jobs.select do |job|
|
|
31
|
+
job["job_class"] == job_class.name && (args.nil? || job["args"] == Arguments.dump(args)) &&
|
|
32
|
+
(queue.nil? || job["queue"] == queue.to_s)
|
|
33
|
+
end
|
|
34
|
+
message = "Expected #{job_class.name}#{" with #{args.inspect}" if args} to be enqueued; " \
|
|
35
|
+
"enqueued: #{enqueued_jobs.map { |j| [j["job_class"], j["args"]] }.inspect}"
|
|
36
|
+
count ? assert_equal(count, matching.size, message) : assert(!matching.empty?, message)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def refute_enqueued(job_class)
|
|
40
|
+
assert(enqueued_jobs.none? { |job| job["job_class"] == job_class.name },
|
|
41
|
+
"Expected no #{job_class.name} to be enqueued")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Runs enqueued jobs (and any they enqueue). With a block: only jobs
|
|
45
|
+
# enqueued inside it. Errors raised by jobs propagate.
|
|
46
|
+
def perform_enqueued_jobs(only: nil)
|
|
47
|
+
return Jobs.adapter.perform_enqueued(only: only) unless block_given?
|
|
48
|
+
|
|
49
|
+
before = enqueued_jobs.map { |job| job["id"] }
|
|
50
|
+
yield
|
|
51
|
+
Jobs.adapter.perform_enqueued(only: only, except_ids: before)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Jobs
|
|
7
|
+
# Processes jobs from the PostgreSQL queue (`gemstack jobs`).
|
|
8
|
+
#
|
|
9
|
+
# - `concurrency` threads each claim and run one job at a time.
|
|
10
|
+
# - A listener thread LISTENs for NOTIFY and wakes idle threads at once;
|
|
11
|
+
# idle threads also re-check every poll_interval.
|
|
12
|
+
# - A reaper releases jobs whose lock is older than lock_timeout (a worker
|
|
13
|
+
# crashed mid-job), so they run again (at-least-once delivery).
|
|
14
|
+
# - stop (SIGINT/SIGTERM) lets running jobs finish for shutdown_timeout,
|
|
15
|
+
# then releases the rest back to the queue.
|
|
16
|
+
#
|
|
17
|
+
# Needs concurrency + 2 database connections (the CLI sizes the pool).
|
|
18
|
+
class Worker
|
|
19
|
+
attr_reader :id
|
|
20
|
+
|
|
21
|
+
def initialize(store: Adapters::Postgres.new, queues: Jobs.config.queues, concurrency: Jobs.config.concurrency,
|
|
22
|
+
poll_interval: Jobs.config.poll_interval, lock_timeout: Jobs.config.lock_timeout,
|
|
23
|
+
shutdown_timeout: Jobs.config.shutdown_timeout)
|
|
24
|
+
@store = store
|
|
25
|
+
@queues = Array(queues).map(&:to_s)
|
|
26
|
+
@concurrency = concurrency
|
|
27
|
+
@poll_interval = poll_interval
|
|
28
|
+
@lock_timeout = lock_timeout
|
|
29
|
+
@shutdown_timeout = shutdown_timeout
|
|
30
|
+
@id = "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(3)}"
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@wakeup = ConditionVariable.new
|
|
33
|
+
@running = false
|
|
34
|
+
@threads = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def running? = @running
|
|
38
|
+
|
|
39
|
+
# Starts the threads and returns immediately.
|
|
40
|
+
def start
|
|
41
|
+
@running = true
|
|
42
|
+
GemStack.logger.info("jobs worker started", worker: @id, queues: @queues.join(","), concurrency: @concurrency)
|
|
43
|
+
@threads = Array.new(@concurrency) { |i| Thread.new { work_loop(i) } }
|
|
44
|
+
@listener = Thread.new { listen_loop }
|
|
45
|
+
@reaper = Thread.new { reap_loop }
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Blocks until SIGINT/SIGTERM, then shuts down gracefully.
|
|
50
|
+
def run
|
|
51
|
+
%w[INT TERM].each { |signal| trap(signal) { @running = false } }
|
|
52
|
+
start
|
|
53
|
+
sleep 0.2 while @running
|
|
54
|
+
shutdown
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stop
|
|
58
|
+
@running = false
|
|
59
|
+
wake
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def shutdown
|
|
63
|
+
stop
|
|
64
|
+
deadline = monotonic + @shutdown_timeout
|
|
65
|
+
@threads.each { |thread| thread.join([deadline - monotonic, 0].max) }
|
|
66
|
+
unfinished = @threads.count(&:alive?)
|
|
67
|
+
@threads.each(&:kill)
|
|
68
|
+
released = @store.release(@id)
|
|
69
|
+
[@listener, @reaper].compact.each { |thread| thread.kill.join(1) }
|
|
70
|
+
GemStack.logger.info("jobs worker stopped", worker: @id, released: released, unfinished: unfinished)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Wakes idle threads (NOTIFY arrived, or shutting down).
|
|
74
|
+
def wake = @mutex.synchronize { @wakeup.broadcast }
|
|
75
|
+
|
|
76
|
+
# Runs one job if one is ready. Returns the outcome, or nil when idle.
|
|
77
|
+
def work_once
|
|
78
|
+
payload = @store.claim(@queues, @id) or return nil
|
|
79
|
+
|
|
80
|
+
outcome = Executor.execute(payload)
|
|
81
|
+
settle(payload, outcome)
|
|
82
|
+
outcome
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def work_loop(_index)
|
|
88
|
+
while @running
|
|
89
|
+
begin
|
|
90
|
+
idle(@poll_interval) unless work_once
|
|
91
|
+
rescue Sequel::DatabaseConnectionError, Sequel::PoolTimeout => e
|
|
92
|
+
GemStack.logger.warn("jobs worker: database unavailable, retrying", error: e.message)
|
|
93
|
+
idle(@poll_interval)
|
|
94
|
+
rescue StandardError => e # a bug in the worker itself; keep the thread alive
|
|
95
|
+
GemStack.logger.error("jobs worker error", error: e, backtrace: Array(e.backtrace).first(10))
|
|
96
|
+
idle(@poll_interval)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def settle(payload, outcome)
|
|
102
|
+
case outcome.status
|
|
103
|
+
when :performed, :discarded then @store.complete(payload["id"])
|
|
104
|
+
when :retry
|
|
105
|
+
@store.reschedule(payload["id"], run_at: outcome.run_at, attempts: outcome.attempt, error: outcome.error)
|
|
106
|
+
when :failed then @store.fail(payload["id"], attempts: outcome.attempt, error: outcome.error)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def idle(seconds)
|
|
111
|
+
@mutex.synchronize { @wakeup.wait(@mutex, seconds) if @running }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def listen_loop
|
|
115
|
+
@store.db.listen(Adapters::Postgres::CHANNEL, loop: ->(_conn) { throw :stop unless @running },
|
|
116
|
+
timeout: @poll_interval) { wake }
|
|
117
|
+
rescue Sequel::DatabaseConnectionError => e
|
|
118
|
+
GemStack.logger.warn("jobs worker: LISTEN failed, falling back to polling", error: e.message)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def reap_loop
|
|
122
|
+
interval = [@lock_timeout / 4.0, 1].max
|
|
123
|
+
while @running
|
|
124
|
+
begin
|
|
125
|
+
released = @store.release_stale(@lock_timeout)
|
|
126
|
+
GemStack.logger.warn("jobs: released stale locks", count: released) if released.positive?
|
|
127
|
+
rescue Sequel::Error => e
|
|
128
|
+
GemStack.logger.warn("jobs reaper error", error: e.message)
|
|
129
|
+
end
|
|
130
|
+
sleep interval
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "gemstack/core"
|
|
6
|
+
|
|
7
|
+
module GemStack
|
|
8
|
+
# Background jobs (ARCHITECTURE §9, DECISIONS D-040).
|
|
9
|
+
#
|
|
10
|
+
# class SendWelcomeEmail < GemStack::Job
|
|
11
|
+
# queue :mailers
|
|
12
|
+
# retry_on Net::ReadTimeout, attempts: 5
|
|
13
|
+
# def perform(user_id) = Mailer.welcome(User.find(user_id))
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# SendWelcomeEmail.perform_later(user.id)
|
|
17
|
+
# SendWelcomeEmail.set(wait: 600).perform_later(user.id)
|
|
18
|
+
#
|
|
19
|
+
# Work is only ever asynchronous when you ask for it with perform_later.
|
|
20
|
+
# Delivery is at-least-once: make perform idempotent.
|
|
21
|
+
module Jobs
|
|
22
|
+
class Config < Settings
|
|
23
|
+
# :postgres (default with gemstack-db), :async (in-process threads),
|
|
24
|
+
# :inline (run immediately), :test (record only; default in tests),
|
|
25
|
+
# :sidekiq, or an adapter object responding to #enqueue(payload).
|
|
26
|
+
setting :adapter, default: lambda {
|
|
27
|
+
if GemStack.env.test? then :test
|
|
28
|
+
elsif defined?(GemStack::DB) then :postgres
|
|
29
|
+
else :async
|
|
30
|
+
end
|
|
31
|
+
}
|
|
32
|
+
setting :default_queue, default: "default"
|
|
33
|
+
setting :default_priority, default: 100 # lower runs first
|
|
34
|
+
setting :default_max_attempts, default: 10
|
|
35
|
+
# Worker settings (`gemstack jobs`).
|
|
36
|
+
setting :queues, default: -> { ENV.fetch("GEMSTACK_JOB_QUEUES", "*").split(",").map(&:strip) }
|
|
37
|
+
setting :concurrency, default: -> { Integer(ENV.fetch("GEMSTACK_JOB_CONCURRENCY", 5)) }
|
|
38
|
+
# Seconds between polls when no NOTIFY arrives (a safety net; NOTIFY wakes workers instantly).
|
|
39
|
+
setting :poll_interval, default: 5
|
|
40
|
+
# A job locked longer than this is assumed abandoned (worker crashed) and is released.
|
|
41
|
+
# Jobs that legitimately run longer must raise it.
|
|
42
|
+
setting :lock_timeout, default: 30 * 60
|
|
43
|
+
# How long a stopping worker waits for running jobs before releasing them.
|
|
44
|
+
setting :shutdown_timeout, default: 25
|
|
45
|
+
setting :table, default: :gemstack_jobs
|
|
46
|
+
# Keep exhausted jobs (failed_at set) for inspection and `gemstack jobs:retry`.
|
|
47
|
+
setting :keep_failed, default: true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
autoload :Worker, "gemstack/jobs/worker"
|
|
51
|
+
autoload :Testing, "gemstack/jobs/testing"
|
|
52
|
+
|
|
53
|
+
# Raised for arguments that can't round-trip through JSON.
|
|
54
|
+
class SerializationError < Error; end
|
|
55
|
+
|
|
56
|
+
# A job's name didn't resolve to a GemStack::Job subclass when it ran.
|
|
57
|
+
class UnknownJob < Error; end
|
|
58
|
+
|
|
59
|
+
Event = Struct.new(:name, :job_class, :job_id, :queue, :attempt, :duration_ms, :error, :run_at, keyword_init: true)
|
|
60
|
+
|
|
61
|
+
@subscribers = Hash.new { |hash, key| hash[key] = [] }
|
|
62
|
+
@mutex = Mutex.new
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
def config = GemStack.config.jobs
|
|
66
|
+
|
|
67
|
+
def adapter
|
|
68
|
+
@adapter || @mutex.synchronize { @adapter ||= build_adapter(config.adapter) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
attr_writer :adapter
|
|
72
|
+
|
|
73
|
+
def build_adapter(setting)
|
|
74
|
+
case setting
|
|
75
|
+
when :postgres, "postgres" then Adapters::Postgres.new
|
|
76
|
+
when :async, "async" then Adapters::Async.new
|
|
77
|
+
when :inline, "inline" then Adapters::Inline.new
|
|
78
|
+
when :test, "test" then Adapters::Test.new
|
|
79
|
+
when :sidekiq, "sidekiq" then Adapters::Sidekiq.new
|
|
80
|
+
else
|
|
81
|
+
raise ConfigurationError, "a job adapter must respond to #enqueue" unless setting.respond_to?(:enqueue)
|
|
82
|
+
|
|
83
|
+
setting
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Instrumentation for metrics/monitoring:
|
|
88
|
+
# GemStack::Jobs.subscribe(:failed) { |event| Sentry.capture_message(...) }
|
|
89
|
+
# Events: :enqueued, :performed, :retried, :failed, :discarded.
|
|
90
|
+
def subscribe(name = :all, &block)
|
|
91
|
+
@mutex.synchronize { @subscribers[name.to_sym] << block }
|
|
92
|
+
block
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def unsubscribe(block)
|
|
96
|
+
@mutex.synchronize { @subscribers.each_value { |list| list.delete(block) } }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def instrument(name, **fields)
|
|
100
|
+
event = Event.new(name: name, **fields)
|
|
101
|
+
log(event)
|
|
102
|
+
(@subscribers[name] + @subscribers[:all]).each do |subscriber|
|
|
103
|
+
subscriber.call(event)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
GemStack.logger.error("job subscriber failed", error: e)
|
|
106
|
+
end
|
|
107
|
+
event
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def reset!
|
|
111
|
+
@adapter = nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def log(event)
|
|
117
|
+
fields = { job: event.job_class, id: event.job_id, queue: event.queue, attempt: event.attempt,
|
|
118
|
+
ms: event.duration_ms, run_at: event.run_at&.utc&.iso8601 }.compact
|
|
119
|
+
fields[:error] = "#{event.error.class}: #{event.error.message}" if event.error
|
|
120
|
+
level = { failed: :error, retried: :warn }.fetch(event.name, :info)
|
|
121
|
+
GemStack.logger.public_send(level, "job.#{event.name}", **fields)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
class << self
|
|
127
|
+
def jobs = Jobs.adapter
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
require_relative "job"
|
|
132
|
+
require_relative "jobs/executor"
|
|
133
|
+
require_relative "jobs/adapters/inline"
|
|
134
|
+
require_relative "jobs/adapters/test"
|
|
135
|
+
require_relative "jobs/adapters/async"
|
|
136
|
+
require_relative "jobs/adapters/postgres"
|
|
137
|
+
require_relative "jobs/adapters/sidekiq"
|
|
138
|
+
|
|
139
|
+
GemStack::Config.namespace(:jobs, GemStack::Jobs::Config)
|
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gemstack-jobs
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Shoaib Malik
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: gemstack-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
email:
|
|
27
|
+
- gemstack26@gmail.com
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- CHANGELOG.md
|
|
33
|
+
- LICENSE.txt
|
|
34
|
+
- README.md
|
|
35
|
+
- lib/gemstack/job.rb
|
|
36
|
+
- lib/gemstack/jobs.rb
|
|
37
|
+
- lib/gemstack/jobs/adapters/async.rb
|
|
38
|
+
- lib/gemstack/jobs/adapters/inline.rb
|
|
39
|
+
- lib/gemstack/jobs/adapters/postgres.rb
|
|
40
|
+
- lib/gemstack/jobs/adapters/sidekiq.rb
|
|
41
|
+
- lib/gemstack/jobs/adapters/test.rb
|
|
42
|
+
- lib/gemstack/jobs/executor.rb
|
|
43
|
+
- lib/gemstack/jobs/testing.rb
|
|
44
|
+
- lib/gemstack/jobs/worker.rb
|
|
45
|
+
homepage: https://github.com/gemstack-rb/gemstack
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
rubygems_mfa_required: 'true'
|
|
50
|
+
source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-jobs
|
|
51
|
+
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-jobs/CHANGELOG.md
|
|
52
|
+
bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
|
|
53
|
+
documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '4.0'
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 4.0.20
|
|
69
|
+
specification_version: 4
|
|
70
|
+
summary: 'GemStack background jobs: a PostgreSQL queue by default, swappable adapters'
|
|
71
|
+
test_files: []
|