stampeded 0.3.1-x86_64-darwin
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/LICENSE +28 -0
- data/README.md +206 -0
- data/exe/stampeded +13 -0
- data/lib/active_job/queue_adapters/stampeded_adapter.rb +58 -0
- data/lib/generators/stampeded/install/install_generator.rb +47 -0
- data/lib/generators/stampeded/install/templates/stampeded.rb +13 -0
- data/lib/stampeded/active_job_ext.rb +89 -0
- data/lib/stampeded/active_job_worker.rb +28 -0
- data/lib/stampeded/client.rb +47 -0
- data/lib/stampeded/configuration.rb +41 -0
- data/lib/stampeded/connection.rb +82 -0
- data/lib/stampeded/continuable_ext.rb +40 -0
- data/lib/stampeded/current.rb +19 -0
- data/lib/stampeded/daemon.rb +51 -0
- data/lib/stampeded/envelope.rb +86 -0
- data/lib/stampeded/errors.rb +68 -0
- data/lib/stampeded/job.rb +85 -0
- data/lib/stampeded/job_context.rb +49 -0
- data/lib/stampeded/lifecycle.rb +30 -0
- data/lib/stampeded/native.rb +233 -0
- data/lib/stampeded/queue_policy.rb +39 -0
- data/lib/stampeded/railtie.rb +23 -0
- data/lib/stampeded/schedule.rb +102 -0
- data/lib/stampeded/schema.rb +68 -0
- data/lib/stampeded/steps.rb +94 -0
- data/lib/stampeded/tasks/stampeded.rake +32 -0
- data/lib/stampeded/version.rb +5 -0
- data/lib/stampeded/worker.rb +224 -0
- data/lib/stampeded.rb +72 -0
- data/libexec/libstampeded_ffi.dylib +0 -0
- data/libexec/stampeded +0 -0
- data/schema/schema.sql +1049 -0
- metadata +110 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ad19088f24b0b655f7688915b34ca674f1299a905c5c9e55bb445c71363556b7
|
|
4
|
+
data.tar.gz: ea4f514ad4803f51af5c5a7b56d5b9b44d5d10ac70e109084d054a9dc0a278bd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9ca24b62e3df42aa0da4b64775d056a2472fafa4f9218cfca9e0a641037b216f0cfca2e62be98cf24824eb68c7188a71a629c341b115df98ce06a92ac6056113
|
|
7
|
+
data.tar.gz: d59ab4e715d9d56eff4ea08ad9070c7c9ff5b5bc360987903c5d405ead0057e16d11b72cb53de685d353addef6a9e07fe5f205ec574166b919d433e8391d69a7
|
data/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Abdelkader Boudih
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# stampeded (Ruby)
|
|
2
|
+
|
|
3
|
+
Rails integration for [Stampeded](https://github.com/seuros/stampeded): an
|
|
4
|
+
ActiveJob adapter, a worker, and a launcher for the daemon.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
gem "stampeded"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# config/application.rb
|
|
12
|
+
config.active_job.queue_adapter = :stampeded
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bin/rails stampeded:install # apply the schema (Postgres 18+)
|
|
17
|
+
bin/rails stampeded:work # run your jobs
|
|
18
|
+
bin/rails stampeded:daemon # scheduler, reaper, pruner, dashboard
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Two processes
|
|
22
|
+
|
|
23
|
+
`stampeded:work` runs your jobs. `stampeded:daemon` runs the Rust daemon:
|
|
24
|
+
scheduler, reaper, pruner, expirer, health checks, HTTP API and dashboard. It
|
|
25
|
+
never runs a job itself. Without the daemon, jobs still run, but there is no
|
|
26
|
+
cron, no recovery of jobs from dead workers, no retention and no dashboard.
|
|
27
|
+
|
|
28
|
+
## Transactional enqueue
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Order.transaction do
|
|
32
|
+
order.save!
|
|
33
|
+
ShipOrderJob.perform_later(order) # same transaction
|
|
34
|
+
end # rolled back? the job is gone too
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The enqueue is an `INSERT` on your own connection. This only holds when the
|
|
38
|
+
jobs live in the same database as your data; with a separate database
|
|
39
|
+
(`config.connection_class`), the job commits on its own.
|
|
40
|
+
|
|
41
|
+
## Jobs other runtimes can run
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class ParseInvoiceJob < ApplicationJob
|
|
45
|
+
queue_as :parsing
|
|
46
|
+
stampeded task: "invoice.parse"
|
|
47
|
+
|
|
48
|
+
def perform(document_id:) = ...
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
ParseInvoiceJob.perform_later(document_id: document.id)
|
|
52
|
+
# task "invoice.parse", args {"document_id": "..."}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Any worker that handles `invoice.parse` can run it: Rust, Go, or this gem's
|
|
56
|
+
worker. Its arguments must be keywords with plain JSON values (no records, times
|
|
57
|
+
or symbols); anything else raises `Stampeded::PortableArgumentsError` at
|
|
58
|
+
enqueue. A job another runtime enqueues as `invoice.parse` runs
|
|
59
|
+
`ParseInvoiceJob` here, with `args` as its keywords.
|
|
60
|
+
|
|
61
|
+
Every other job works as before. Keyword calls with plain values are stored the
|
|
62
|
+
same standard way; anything else (records, positional arguments) keeps
|
|
63
|
+
ActiveJob's own format, which only a Ruby worker can run.
|
|
64
|
+
|
|
65
|
+
## Retries
|
|
66
|
+
|
|
67
|
+
ActiveJob's `retry_on` raises `Stampeded::RetryConflict` when the job class
|
|
68
|
+
loads: it re-enqueues retries as new jobs, while stampeded counts attempts on
|
|
69
|
+
the job itself. Declare stampeded's options instead:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class ChargeCard < ApplicationJob
|
|
73
|
+
stampeded max_attempts: 5,
|
|
74
|
+
backoff_base_ms: 1_000, backoff_max_ms: 300_000,
|
|
75
|
+
dead_letter_queue: "payments_dlq",
|
|
76
|
+
expire_seconds: 120
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- `raise Stampeded::RetryIn.new(seconds)` picks the next delay, e.g. a
|
|
81
|
+
server's `Retry-After`.
|
|
82
|
+
- `stampeded_attempt` is the attempt number. ActiveJob's `executions` is always
|
|
83
|
+
1 here.
|
|
84
|
+
- `discard_on` works as usual.
|
|
85
|
+
- Jobs inside installed gems (Rails' `ActiveStorage::AnalyzeJob`, for one) may
|
|
86
|
+
keep `retry_on`; they get a warning at load instead.
|
|
87
|
+
|
|
88
|
+
Other options: `unique:` / `unique_period:` (one waiting job per key) and
|
|
89
|
+
`destination:`.
|
|
90
|
+
|
|
91
|
+
## Continuations
|
|
92
|
+
|
|
93
|
+
Rails 8.1 continuations work as written:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class ImportJob < ApplicationJob
|
|
97
|
+
include ActiveJob::Continuable
|
|
98
|
+
|
|
99
|
+
def perform(import_id)
|
|
100
|
+
step :rows, start: 0 do |rows|
|
|
101
|
+
Row.where(import_id:).find_each(start: rows.cursor) do |row|
|
|
102
|
+
row.process!
|
|
103
|
+
rows.advance!(from: row.id)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- Progress is kept on the job's row, so a crashed worker's job resumes too.
|
|
111
|
+
- A deploy interrupts the job at its next checkpoint; it goes back to the queue
|
|
112
|
+
without spending an attempt and resumes on the same row.
|
|
113
|
+
- An error after progress is an attempt: `max_attempts` and backoff apply, and
|
|
114
|
+
the next attempt resumes from the last checkpoint.
|
|
115
|
+
|
|
116
|
+
## Recurring jobs
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
Stampeded::Schedule.recurring(
|
|
120
|
+
key: "nightly-report",
|
|
121
|
+
job: ReportJob,
|
|
122
|
+
cron: "0 3 * * *",
|
|
123
|
+
args: [Date.current],
|
|
124
|
+
timezone: "America/New_York"
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The daemon fires it; a new schedule first runs at its next occurrence. An
|
|
129
|
+
expression that names its hours runs once on a DST fall-back day; one using `*`
|
|
130
|
+
or a step runs on real elapsed time. `Stampeded::Schedule.pause`, `.remove` and
|
|
131
|
+
`.all` manage them.
|
|
132
|
+
|
|
133
|
+
## Workers
|
|
134
|
+
|
|
135
|
+
The pool is threads, which suits IO-bound jobs; for CPU-bound jobs run more
|
|
136
|
+
processes.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
QUEUE=mail CONCURRENCY=10 bin/rails stampeded:work
|
|
140
|
+
QUEUES=default,mail,reports bin/rails stampeded:work # one pool, several queues
|
|
141
|
+
QUEUES='*' QUEUES_EXCEPT=payments_dlq bin/rails stampeded:work
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Or configure it once:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
config.queues = %w[default mail reports] # or :all
|
|
148
|
+
config.except_queues = %w[payments_dlq] # with :all
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- With named queues, name every queue your jobs use: a job in a queue nobody
|
|
152
|
+
polls waits forever.
|
|
153
|
+
- With `:all`, the worker serves every queue that has work. Leave out
|
|
154
|
+
dead-letter queues and queues other languages' workers own: this worker runs
|
|
155
|
+
everything as ActiveJob and discards a job whose class it cannot find.
|
|
156
|
+
- `SIGTERM` lets in-flight jobs finish before exiting. Jobs still running after
|
|
157
|
+
`shutdown_timeout` are reclaimed by the daemon.
|
|
158
|
+
- The worker runs on `libstampeded_ffi`, the same core every runtime's worker
|
|
159
|
+
uses; see [The binaries](#the-binaries). `STAMPEDED_LOG=1` logs each job;
|
|
160
|
+
`STAMPEDED_LOG=debug` (or `info`, `warn`, ...) also sets the core's level.
|
|
161
|
+
|
|
162
|
+
## Queue policies
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
Stampeded::QueuePolicy.set("extraction", max_running: 2) # at most two at a time
|
|
166
|
+
Stampeded::QueuePolicy.set("mail", max_wait: 60) # first in line once its next job waits a minute
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The daemon turns these into a verdict per queue every second; the worker claims
|
|
170
|
+
rushed queues first and skips held ones. `max_running` also limits what one
|
|
171
|
+
worker process holds from that queue, with or without the daemon.
|
|
172
|
+
|
|
173
|
+
## Without Rails
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
Stampeded.config.database_url = ENV["DATABASE_URL"]
|
|
177
|
+
|
|
178
|
+
Stampeded.enqueue(queue: "mail", task: "welcome", args: { user_id: 42 })
|
|
179
|
+
|
|
180
|
+
worker = Stampeded::Worker.new(queue: "mail")
|
|
181
|
+
worker.register("welcome") { |job| Mailer.welcome(job.args["user_id"]).deliver_now }
|
|
182
|
+
worker.run
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## The binaries
|
|
186
|
+
|
|
187
|
+
Platform gems include the daemon and the worker's library, `libstampeded_ffi`.
|
|
188
|
+
Otherwise (a platform without them, or the gem from git), build both and point
|
|
189
|
+
at them:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
cargo build --release -p stampeded -p stampeded-ffi --bin stampeded --lib
|
|
193
|
+
export STAMPEDED_INSTALL_DIR=/path/to/stampeded/target/release
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The worker, schedules, steps and job state checks load the library; a process
|
|
197
|
+
that only enqueues needs neither.
|
|
198
|
+
|
|
199
|
+
## Tests
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
createdb stampeded_test
|
|
203
|
+
STAMPEDED_TEST_DATABASE_URL=postgres://localhost/stampeded_test bundle exec rake test
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`rake test` builds the library first, unless `STAMPEDED_INSTALL_DIR` names one.
|
data/exe/stampeded
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "stampeded/version"
|
|
5
|
+
require "stampeded/errors"
|
|
6
|
+
require "stampeded/daemon"
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
Stampeded::Daemon.exec!(*ARGV)
|
|
10
|
+
rescue Stampeded::DaemonNotFound => e
|
|
11
|
+
warn e.message
|
|
12
|
+
exit 1
|
|
13
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
require "stampeded"
|
|
5
|
+
|
|
6
|
+
module ActiveJob
|
|
7
|
+
module QueueAdapters
|
|
8
|
+
class StampededAdapter < AbstractAdapter
|
|
9
|
+
def initialize(**options)
|
|
10
|
+
super()
|
|
11
|
+
@options = options
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def enqueue(job)
|
|
15
|
+
job.provider_job_id = insert(job, nil)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def enqueue_at(job, timestamp)
|
|
19
|
+
job.provider_job_id = insert(job, Time.at(timestamp).utc)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def enqueue_all(jobs)
|
|
23
|
+
jobs.count do |job|
|
|
24
|
+
id = insert(job, job.scheduled_at && Time.at(job.scheduled_at).utc)
|
|
25
|
+
job.provider_job_id = id
|
|
26
|
+
!id.nil?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stopping? = Stampeded.stopping?
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def insert(job, scheduled_at)
|
|
35
|
+
options = job.class.try(:stampeded_options) || {}
|
|
36
|
+
|
|
37
|
+
task, args, meta = Stampeded::Envelope.from_active_job(job)
|
|
38
|
+
|
|
39
|
+
Stampeded::Client.enqueue(
|
|
40
|
+
queue: job.queue_name.to_s,
|
|
41
|
+
task: task,
|
|
42
|
+
args: args,
|
|
43
|
+
meta: meta,
|
|
44
|
+
scheduled_at: scheduled_at,
|
|
45
|
+
priority: job.priority,
|
|
46
|
+
max_attempts: options[:max_attempts],
|
|
47
|
+
destination: options[:destination],
|
|
48
|
+
dead_letter_queue: options[:dead_letter_queue],
|
|
49
|
+
expire_seconds: options[:expire_seconds],
|
|
50
|
+
unique: options[:unique]&.then { |u| u.respond_to?(:call) ? u.call(job) : u },
|
|
51
|
+
unique_period: options[:unique_period]&.to_s,
|
|
52
|
+
backoff_base_ms: options[:backoff_base_ms],
|
|
53
|
+
backoff_max_ms: options[:backoff_max_ms]
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Stampeded
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
class_option :queue, type: :string, default: "default",
|
|
11
|
+
desc: "Queue to provision a partition for"
|
|
12
|
+
|
|
13
|
+
def create_initializer
|
|
14
|
+
template "stampeded.rb", "config/initializers/stampeded.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def install_schema
|
|
18
|
+
if Stampeded::Schema.installed?
|
|
19
|
+
say_status :schema, "already installed, skipping", :blue
|
|
20
|
+
else
|
|
21
|
+
say_status :schema, "applying stampeded schema (Postgres 18+)"
|
|
22
|
+
Stampeded::Schema.install!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Stampeded.create_queue(options[:queue])
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
say_status :error, e.message, :red
|
|
28
|
+
say <<~MSG
|
|
29
|
+
The schema was not applied. Check that the database exists and runs
|
|
30
|
+
Postgres 18 or newer, then: bin/rails stampeded:install
|
|
31
|
+
MSG
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def show_next_steps
|
|
35
|
+
say <<~MSG
|
|
36
|
+
|
|
37
|
+
Stampeded installed.
|
|
38
|
+
|
|
39
|
+
config.active_job.queue_adapter = :stampeded # in config/application.rb
|
|
40
|
+
|
|
41
|
+
bin/rails stampeded:work # runs your jobs
|
|
42
|
+
bin/rails stampeded:daemon # scheduler, reaper, pruner, dashboard
|
|
43
|
+
MSG
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "continuable_ext"
|
|
4
|
+
|
|
5
|
+
module Stampeded
|
|
6
|
+
module ActiveJobExt
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(ClassMethods)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.installed_gem?(file)
|
|
12
|
+
Gem.path.any? { |dir| file.start_with?(File.join(dir, "")) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def stampeded_checkpoint
|
|
16
|
+
Stampeded::Current.context&.checkpoint
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def stampeded_checkpoint!(state = nil, conn: nil, **rest)
|
|
20
|
+
ctx = Stampeded::Current.context
|
|
21
|
+
raise Stampeded::Error, "no Stampeded job context: is this running under a Stampeded worker?" if ctx.nil?
|
|
22
|
+
|
|
23
|
+
ctx.checkpoint!(state || rest, conn: conn)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stampeded_step(name, cursor: nil, &)
|
|
27
|
+
ctx = Stampeded::Current.context
|
|
28
|
+
raise Stampeded::Error, "no Stampeded job context: is this running under a Stampeded worker?" if ctx.nil?
|
|
29
|
+
|
|
30
|
+
ctx.step(name, cursor: cursor, &)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stampeded_resumed?
|
|
34
|
+
!Stampeded::Current.context&.checkpoint.nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stampeded_attempt
|
|
38
|
+
Stampeded::Current.context&.attempt
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module ClassMethods
|
|
42
|
+
def stampeded_task = @stampeded_task
|
|
43
|
+
|
|
44
|
+
def stampeded_options
|
|
45
|
+
@stampeded_options ||= superclass.respond_to?(:stampeded_options) ? superclass.stampeded_options.dup : {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stampeded(**options)
|
|
49
|
+
unknown = options.keys - %i[task max_attempts destination dead_letter_queue
|
|
50
|
+
expire_seconds unique unique_period
|
|
51
|
+
backoff_base_ms backoff_max_ms]
|
|
52
|
+
raise ArgumentError, "unknown stampeded options: #{unknown.join(", ")}" if unknown.any?
|
|
53
|
+
|
|
54
|
+
if (task = options.delete(:task))
|
|
55
|
+
Stampeded::Envelope.register(task, self)
|
|
56
|
+
@stampeded_task = task.to_s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
base = options[:backoff_base_ms]
|
|
60
|
+
if base && base.to_i > 32_767
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"backoff_base_ms is at most 32767 (smallint on the row), got #{base}. " \
|
|
63
|
+
"It is the FIRST retry delay, which doubles from there; the ceiling " \
|
|
64
|
+
"you want is probably backoff_max_ms, which is an integer."
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
stampeded_options.merge!(options)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def retry_on(*, **)
|
|
71
|
+
if stampeded_adapter?
|
|
72
|
+
site = caller_locations(1, 1).first&.path.to_s
|
|
73
|
+
raise Stampeded::RetryConflict, name || "This job" unless Stampeded::ActiveJobExt.installed_gem?(site)
|
|
74
|
+
|
|
75
|
+
Kernel.warn "[stampeded] #{name} (#{site}) declares retry_on; its retries are not counted as attempts"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
super
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def stampeded_adapter?
|
|
82
|
+
adapter = respond_to?(:queue_adapter) ? queue_adapter : nil
|
|
83
|
+
adapter.class.name.to_s.include?("Stampeded")
|
|
84
|
+
rescue StandardError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stampeded"
|
|
4
|
+
|
|
5
|
+
module Stampeded
|
|
6
|
+
class ActiveJobWorker < Worker
|
|
7
|
+
def initialize(**kwargs)
|
|
8
|
+
super
|
|
9
|
+
@handlers.default_proc = proc { |_h, _task| method(:execute_active_job) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def execute_active_job(job)
|
|
15
|
+
payload = Envelope.to_active_job_payload(id: job.id, queue: job.queue, task: job.task,
|
|
16
|
+
args: job.args, meta: job.meta, checkpoint: job.checkpoint)
|
|
17
|
+
|
|
18
|
+
klass = payload["job_class"]
|
|
19
|
+
begin
|
|
20
|
+
Object.const_get(klass)
|
|
21
|
+
rescue NameError
|
|
22
|
+
raise UnknownTask, "#{klass} is not defined in this worker"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Current.with(job) { ActiveJob::Base.execute(payload) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Stampeded
|
|
6
|
+
module Client
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def enqueue(queue:, task:, args: {}, meta: {}, scheduled_at: nil, priority: nil,
|
|
10
|
+
max_attempts: nil, tenant_id: nil, unique: nil, unique_period: nil,
|
|
11
|
+
destination: nil, dead_letter_queue: nil, expire_seconds: nil,
|
|
12
|
+
backoff_base_ms: nil, backoff_max_ms: nil)
|
|
13
|
+
sql = <<~SQL
|
|
14
|
+
INSERT INTO stampeded_jobs
|
|
15
|
+
(queue, task, args, tenant_id, priority, max_attempts, scheduled_at,
|
|
16
|
+
dedup_scope, dedup_period, destination, dead_letter_queue, expire_seconds,
|
|
17
|
+
backoff_base_ms, backoff_max_ms, meta)
|
|
18
|
+
VALUES ($1, $2, $3, $4, coalesce($5, 0), coalesce($6, 25), coalesce($7, now()),
|
|
19
|
+
$8, $9, $10, $11, coalesce($12, 3600),
|
|
20
|
+
coalesce($13, 1000), coalesce($14, 3600000), $15)
|
|
21
|
+
ON CONFLICT DO NOTHING
|
|
22
|
+
RETURNING id
|
|
23
|
+
SQL
|
|
24
|
+
|
|
25
|
+
params = [
|
|
26
|
+
queue, task, JSON.generate(args.nil? ? {} : args), tenant_id, priority, max_attempts,
|
|
27
|
+
scheduled_at&.then { |t| t.utc.iso8601(6) },
|
|
28
|
+
unique, unique_period, destination, dead_letter_queue, expire_seconds,
|
|
29
|
+
backoff_base_ms, backoff_max_ms, JSON.generate(meta.nil? ? {} : meta)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
Connection.translating_errors do
|
|
33
|
+
Connection.with { |c| c.exec_params(sql, params) }
|
|
34
|
+
.then { |r| r.ntuples.zero? ? nil : r[0]["id"] }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def fetch(queue, id)
|
|
39
|
+
Connection.translating_errors do
|
|
40
|
+
Connection.with do |c|
|
|
41
|
+
r = c.exec_params("SELECT * FROM stampeded_jobs WHERE queue = $1 AND id = $2", [queue, id])
|
|
42
|
+
r.ntuples.zero? ? nil : Job.new(r[0])
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stampeded
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :database_url
|
|
6
|
+
|
|
7
|
+
attr_accessor :default_queue
|
|
8
|
+
|
|
9
|
+
attr_accessor :queues
|
|
10
|
+
|
|
11
|
+
attr_accessor :except_queues
|
|
12
|
+
|
|
13
|
+
attr_accessor :concurrency
|
|
14
|
+
|
|
15
|
+
attr_accessor :batch_size
|
|
16
|
+
|
|
17
|
+
attr_accessor :poll_interval
|
|
18
|
+
|
|
19
|
+
attr_accessor :heartbeat_interval
|
|
20
|
+
|
|
21
|
+
attr_accessor :shutdown_timeout
|
|
22
|
+
|
|
23
|
+
attr_accessor :connection_class
|
|
24
|
+
|
|
25
|
+
attr_accessor :job_timeout
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@database_url = ENV.fetch("STAMPEDED_DATABASE_URL", nil) || ENV.fetch("DATABASE_URL", nil)
|
|
29
|
+
@default_queue = "default"
|
|
30
|
+
@queues = nil
|
|
31
|
+
@except_queues = nil
|
|
32
|
+
@concurrency = 5
|
|
33
|
+
@batch_size = 5
|
|
34
|
+
@poll_interval = 1.0
|
|
35
|
+
@heartbeat_interval = 30.0
|
|
36
|
+
@shutdown_timeout = 25.0
|
|
37
|
+
@connection_class = nil
|
|
38
|
+
@job_timeout = 900.0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pg"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Stampeded
|
|
7
|
+
module Connection
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def with(&block)
|
|
11
|
+
if active_record?
|
|
12
|
+
ar_class.connection.raw_connection.then(&block)
|
|
13
|
+
else
|
|
14
|
+
own(&block)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def own
|
|
19
|
+
conn = PG.connect(connection_params)
|
|
20
|
+
begin
|
|
21
|
+
yield conn
|
|
22
|
+
ensure
|
|
23
|
+
conn.close
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def connection_params
|
|
28
|
+
explicit = Stampeded.config.database_url
|
|
29
|
+
|
|
30
|
+
if active_record?
|
|
31
|
+
cfg = ar_class.connection_db_config.configuration_hash
|
|
32
|
+
return {
|
|
33
|
+
host: cfg[:host] || "localhost",
|
|
34
|
+
port: cfg[:port],
|
|
35
|
+
dbname: cfg[:database],
|
|
36
|
+
user: cfg[:username],
|
|
37
|
+
password: cfg[:password],
|
|
38
|
+
**cfg.slice(*TLS_KEYS)
|
|
39
|
+
}.compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
explicit or raise Error, <<~MSG
|
|
43
|
+
No database configured.
|
|
44
|
+
|
|
45
|
+
Set Stampeded.config.database_url, or STAMPEDED_DATABASE_URL, or run
|
|
46
|
+
inside a Rails app where ActiveRecord is configured.
|
|
47
|
+
MSG
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
TLS_KEYS = %i[sslmode sslrootcert sslcert sslkey].freeze
|
|
51
|
+
|
|
52
|
+
def url(params = connection_params)
|
|
53
|
+
return params if params.is_a?(String)
|
|
54
|
+
|
|
55
|
+
query = params.filter_map { |k, v| "#{k}=#{URI.encode_www_form_component(v.to_s)}" unless v.nil? }
|
|
56
|
+
"postgres://?#{query.join("&")}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def translating_errors
|
|
60
|
+
yield
|
|
61
|
+
rescue PG::UndefinedTable, PG::UndefinedFunction => e
|
|
62
|
+
raise SchemaMissing, e if e.message.include?("stampeded_")
|
|
63
|
+
|
|
64
|
+
raise
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ar_class
|
|
68
|
+
name = Stampeded.config.connection_class
|
|
69
|
+
return ActiveRecord::Base if name.nil?
|
|
70
|
+
|
|
71
|
+
Object.const_get(name.to_s)
|
|
72
|
+
rescue NameError
|
|
73
|
+
raise Error, "Stampeded.config.connection_class is #{name.inspect}, which does not exist"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def active_record?
|
|
77
|
+
defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:connection_db_config)
|
|
78
|
+
rescue StandardError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|