gemstack 0.2.5 → 0.3.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 +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -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 +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +251 -39
|
@@ -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::Database.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 || (store.respond_to?(:postgres?) && store.postgres? ? 5 : 1)
|
|
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 } if @store.respond_to?(:postgres?) && @store.postgres?
|
|
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::Database::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,144 @@
|
|
|
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).
|
|
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
|
+
# :database (default with gemstack/db loaded: the app's PostgreSQL, MySQL or
|
|
24
|
+
# SQLite; :postgres is an alias), :async (in-process threads),
|
|
25
|
+
# :inline (run immediately), :test (record only; default in tests),
|
|
26
|
+
# :sidekiq, or an adapter object responding to #enqueue(payload).
|
|
27
|
+
setting :adapter, default: lambda {
|
|
28
|
+
if GemStack.env.test? then :test
|
|
29
|
+
elsif defined?(GemStack::DB) then :database
|
|
30
|
+
else :async
|
|
31
|
+
end
|
|
32
|
+
}
|
|
33
|
+
setting :default_queue, default: "default"
|
|
34
|
+
setting :default_priority, default: 100 # lower runs first
|
|
35
|
+
setting :default_max_attempts, default: 10
|
|
36
|
+
# Worker settings (`gemstack jobs`).
|
|
37
|
+
setting :queues, default: -> { ENV.fetch("GEMSTACK_JOB_QUEUES", "*").split(",").map(&:strip) }
|
|
38
|
+
setting :concurrency, default: -> { Integer(ENV.fetch("GEMSTACK_JOB_CONCURRENCY", 5)) }
|
|
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
|
|
42
|
+
# A job locked longer than this is assumed abandoned (worker crashed) and is released.
|
|
43
|
+
# Jobs that legitimately run longer must raise it.
|
|
44
|
+
setting :lock_timeout, default: 30 * 60
|
|
45
|
+
# How long a stopping worker waits for running jobs before releasing them.
|
|
46
|
+
setting :shutdown_timeout, default: 25
|
|
47
|
+
setting :table, default: :gemstack_jobs
|
|
48
|
+
# Keep exhausted jobs (failed_at set) for inspection and `gemstack jobs:retry`.
|
|
49
|
+
setting :keep_failed, default: true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
autoload :Worker, "gemstack/jobs/worker"
|
|
53
|
+
autoload :Testing, "gemstack/jobs/testing"
|
|
54
|
+
|
|
55
|
+
# Raised for arguments that can't round-trip through JSON.
|
|
56
|
+
class SerializationError < Error; end
|
|
57
|
+
|
|
58
|
+
# A job's name didn't resolve to a GemStack::Job subclass when it ran.
|
|
59
|
+
class UnknownJob < Error; end
|
|
60
|
+
|
|
61
|
+
Event = Struct.new(:name, :job_class, :job_id, :queue, :attempt, :duration_ms, :error, :run_at, keyword_init: true)
|
|
62
|
+
|
|
63
|
+
@subscribers = Hash.new { |hash, key| hash[key] = [] }
|
|
64
|
+
@mutex = Mutex.new
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
def config = GemStack.config.jobs
|
|
68
|
+
|
|
69
|
+
def adapter
|
|
70
|
+
@adapter || @mutex.synchronize { @adapter ||= build_adapter(config.adapter) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
attr_writer :adapter
|
|
74
|
+
|
|
75
|
+
def build_adapter(setting)
|
|
76
|
+
case setting
|
|
77
|
+
when :database, "database", :postgres, "postgres" then Adapters::Database.new
|
|
78
|
+
when :async, "async" then Adapters::Async.new
|
|
79
|
+
when :inline, "inline" then Adapters::Inline.new
|
|
80
|
+
when :test, "test" then Adapters::Test.new
|
|
81
|
+
when :sidekiq, "sidekiq" then Adapters::Sidekiq.new
|
|
82
|
+
else
|
|
83
|
+
raise ConfigurationError, "a job adapter must respond to #enqueue" unless setting.respond_to?(:enqueue)
|
|
84
|
+
|
|
85
|
+
setting
|
|
86
|
+
end
|
|
87
|
+
end
|
|
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
|
+
|
|
92
|
+
# Instrumentation for metrics/monitoring:
|
|
93
|
+
# GemStack::Jobs.subscribe(:failed) { |event| Sentry.capture_message(...) }
|
|
94
|
+
# Events: :enqueued, :performed, :retried, :failed, :discarded.
|
|
95
|
+
def subscribe(name = :all, &block)
|
|
96
|
+
@mutex.synchronize { @subscribers[name.to_sym] << block }
|
|
97
|
+
block
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def unsubscribe(block)
|
|
101
|
+
@mutex.synchronize { @subscribers.each_value { |list| list.delete(block) } }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def instrument(name, **fields)
|
|
105
|
+
event = Event.new(name: name, **fields)
|
|
106
|
+
log(event)
|
|
107
|
+
(@subscribers[name] + @subscribers[:all]).each do |subscriber|
|
|
108
|
+
subscriber.call(event)
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
GemStack.logger.error("job subscriber failed", error: e)
|
|
111
|
+
end
|
|
112
|
+
event
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reset!
|
|
116
|
+
@adapter = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def log(event)
|
|
122
|
+
fields = { job: event.job_class, id: event.job_id, queue: event.queue, attempt: event.attempt,
|
|
123
|
+
ms: event.duration_ms, run_at: event.run_at&.utc&.iso8601 }.compact
|
|
124
|
+
fields[:error] = "#{event.error.class}: #{event.error.message}" if event.error
|
|
125
|
+
level = { failed: :error, retried: :warn }.fetch(event.name, :info)
|
|
126
|
+
GemStack.logger.public_send(level, "job.#{event.name}", **fields)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
class << self
|
|
132
|
+
def jobs = Jobs.adapter
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
require_relative "job"
|
|
137
|
+
require_relative "jobs/executor"
|
|
138
|
+
require_relative "jobs/adapters/inline"
|
|
139
|
+
require_relative "jobs/adapters/test"
|
|
140
|
+
require_relative "jobs/adapters/async"
|
|
141
|
+
require_relative "jobs/adapters/database"
|
|
142
|
+
require_relative "jobs/adapters/sidekiq"
|
|
143
|
+
|
|
144
|
+
GemStack::Config.namespace(:jobs, GemStack::Jobs::Config)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module GemStack
|
|
7
|
+
# Structured, thread-safe logger.
|
|
8
|
+
#
|
|
9
|
+
# logger.info("request", method: "GET", path: "/api/products", status: 200)
|
|
10
|
+
# logger.debug { "expensive #{computation}" }
|
|
11
|
+
# logger.with(request_id: id).warn("slow query", ms: 812)
|
|
12
|
+
#
|
|
13
|
+
# Formats:
|
|
14
|
+
# :pretty 12:00:01.123 INFO request method=GET path=/api/products status=200
|
|
15
|
+
# :json {"time":"...","level":"info","msg":"request","method":"GET",...}
|
|
16
|
+
#
|
|
17
|
+
# Fields whose key matches a filter (e.g. "password", "token") are replaced
|
|
18
|
+
# with "[FILTERED]", recursively. It also answers the standard ::Logger
|
|
19
|
+
# methods (#info, #level=, #debug?, ...) so it can be handed to other gems.
|
|
20
|
+
class Logger
|
|
21
|
+
LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
|
|
22
|
+
LABELS = { debug: "DEBUG", info: "INFO ", warn: "WARN ", error: "ERROR", fatal: "FATAL" }.freeze
|
|
23
|
+
COLORS = { debug: 90, info: 36, warn: 33, error: 31, fatal: 35 }.freeze
|
|
24
|
+
FILTERED = "[FILTERED]"
|
|
25
|
+
|
|
26
|
+
attr_reader :level, :format, :context
|
|
27
|
+
|
|
28
|
+
def initialize(output = $stdout, level: :info, format: :pretty, filter: [], context: {}, color: nil, mutex: nil)
|
|
29
|
+
@output = output
|
|
30
|
+
# Log lines must appear when written, also when stdout is a pipe (e.g.
|
|
31
|
+
# under `gemstack dev` or a process manager) where Ruby would buffer them.
|
|
32
|
+
@output.sync = true if @output.respond_to?(:sync=)
|
|
33
|
+
self.level = level
|
|
34
|
+
@format = format.to_sym
|
|
35
|
+
@filter = Array(filter).map { |f| f.to_s.downcase }
|
|
36
|
+
@context = context
|
|
37
|
+
@color = color.nil? ? output.respond_to?(:tty?) && output.tty? : color
|
|
38
|
+
@mutex = mutex || Mutex.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def level=(value)
|
|
42
|
+
value = value.to_s.downcase.to_sym
|
|
43
|
+
raise ArgumentError, "unknown log level #{value.inspect}" unless LEVELS.key?(value)
|
|
44
|
+
|
|
45
|
+
@level = value
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A child logger that adds fields to every entry. Shares output and lock.
|
|
49
|
+
def with(**fields)
|
|
50
|
+
self.class.new(@output, level: @level, format: @format, filter: @filter, context: @context.merge(fields),
|
|
51
|
+
color: @color, mutex: @mutex)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
LEVELS.each_key do |name|
|
|
55
|
+
define_method(name) { |message = nil, **fields, &block| log(name, message, fields, &block) }
|
|
56
|
+
define_method(:"#{name}?") { enabled?(name) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def enabled?(severity) = @output && LEVELS.fetch(severity) >= LEVELS.fetch(@level)
|
|
60
|
+
|
|
61
|
+
# ::Logger compatibility: `add(severity_int, message)`.
|
|
62
|
+
def add(severity, message = nil, progname = nil, &)
|
|
63
|
+
name = LEVELS.key(severity) || :info
|
|
64
|
+
log(name, message || progname, {}, &)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def <<(message) = info(message.to_s.chomp)
|
|
68
|
+
|
|
69
|
+
def filter(fields)
|
|
70
|
+
return fields if @filter.empty?
|
|
71
|
+
|
|
72
|
+
fields.to_h do |key, value|
|
|
73
|
+
if filtered_key?(key) then [key, FILTERED]
|
|
74
|
+
elsif value.is_a?(Hash) then [key, filter(value)]
|
|
75
|
+
else [key, value]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def log(severity, message, fields)
|
|
83
|
+
return true unless enabled?(severity)
|
|
84
|
+
|
|
85
|
+
message = yield if message.nil? && block_given?
|
|
86
|
+
fields = filter(@context.empty? ? fields : @context.merge(fields))
|
|
87
|
+
line = @format == :json ? json_line(severity, message, fields) : pretty_line(severity, message, fields)
|
|
88
|
+
@mutex.synchronize { @output.write(line) }
|
|
89
|
+
true
|
|
90
|
+
rescue IOError, SystemCallError
|
|
91
|
+
true # never let logging take the application down
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def filtered_key?(key)
|
|
95
|
+
key = key.to_s.downcase
|
|
96
|
+
@filter.any? { |f| key.include?(f) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def json_line(severity, message, fields)
|
|
100
|
+
entry = { time: Time.now.utc.iso8601(3), level: severity, msg: message.to_s }
|
|
101
|
+
fields.each { |key, value| entry[key] = serializable(value) }
|
|
102
|
+
"#{JSON.generate(entry)}\n"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def pretty_line(severity, message, fields)
|
|
106
|
+
label = LABELS[severity]
|
|
107
|
+
label = "\e[#{COLORS[severity]}m#{label}\e[0m" if @color
|
|
108
|
+
pairs = fields.map { |key, value| "#{key}=#{pretty_value(value)}" }
|
|
109
|
+
[Time.now.strftime("%H:%M:%S.%L"), label, message, *pairs].join(" ") << "\n"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def pretty_value(value)
|
|
113
|
+
case value
|
|
114
|
+
when String then value.match?(/[\s"=]/) ? value.inspect : value
|
|
115
|
+
when nil then "nil"
|
|
116
|
+
when Hash, Array then JSON.generate(serializable(value))
|
|
117
|
+
else value.to_s
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def serializable(value)
|
|
122
|
+
case value
|
|
123
|
+
when String, Integer, Float, true, false, nil then value
|
|
124
|
+
when Hash then value.transform_values { |v| serializable(v) }
|
|
125
|
+
when Array then value.map { |v| serializable(v) }
|
|
126
|
+
when Exception then { class: value.class.name, message: value.message }
|
|
127
|
+
else value.to_s
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gemstack/jobs"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Mail
|
|
7
|
+
# Delivers a mailer action in the background (Delivery#deliver_later).
|
|
8
|
+
# Retried like any job; SMTP errors are transient more often than not.
|
|
9
|
+
class DeliveryJob < GemStack::Job
|
|
10
|
+
def self.queue(name = nil) = name ? super : (@queue || Mail.config.queue)
|
|
11
|
+
|
|
12
|
+
def perform(mailer_name, action, args)
|
|
13
|
+
mailer = Object.const_get(mailer_name)
|
|
14
|
+
raise ArgumentError, "#{mailer_name} is not a GemStack::Mailer" unless mailer.is_a?(Class) && mailer < Mailer
|
|
15
|
+
|
|
16
|
+
Mailer::Delivery.new(mailer, action, args).deliver_now
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gemstack/mail"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Mail
|
|
7
|
+
# Test helpers (included into GemStack::TestCase by `gemstack add auth`):
|
|
8
|
+
#
|
|
9
|
+
# assert_emails(1) { post_json "/api/auth/password/forgot", { email: user.email } }
|
|
10
|
+
# assert_equal [user.email], last_email.to
|
|
11
|
+
module Testing
|
|
12
|
+
def before_setup
|
|
13
|
+
super
|
|
14
|
+
Mail.deliveries.clear
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def deliveries = Mail.deliveries
|
|
18
|
+
def last_email = Mail.deliveries.last
|
|
19
|
+
|
|
20
|
+
def assert_emails(count, &block)
|
|
21
|
+
before = Mail.deliveries.size
|
|
22
|
+
adapter = defined?(GemStack::Jobs) && GemStack::Jobs.adapter
|
|
23
|
+
adapter = nil unless adapter.respond_to?(:perform_enqueued)
|
|
24
|
+
queued = adapter ? adapter.enqueued.map { |job| job["id"] } : []
|
|
25
|
+
block&.call
|
|
26
|
+
# deliver_later mail sent inside the block counts too.
|
|
27
|
+
adapter&.perform_enqueued(only: ["GemStack::Mail::DeliveryJob"], except_ids: queued)
|
|
28
|
+
assert_equal count, Mail.deliveries.size - before,
|
|
29
|
+
"Expected #{count} email(s), got #{Mail.deliveries.size - before}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|