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,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# English inflections used by conventions (routes, generators, constants).
|
|
5
|
+
#
|
|
6
|
+
# Inflector.pluralize("category") # => "categories"
|
|
7
|
+
# Inflector.camelize("inventory_item") # => "InventoryItem"
|
|
8
|
+
# Inflector.underscore("InventoryItem")# => "inventory_item"
|
|
9
|
+
#
|
|
10
|
+
# Extend with Inflector.irregular("person", "people") or
|
|
11
|
+
# Inflector.uncountable("equipment") — typically in config/app.rb.
|
|
12
|
+
module Inflector
|
|
13
|
+
PLURALS = [
|
|
14
|
+
[/(quiz)\z/i, '\1zes'],
|
|
15
|
+
[/\A(ox)\z/i, '\1en'],
|
|
16
|
+
[/(matr|vert|ind)(?:ix|ex)\z/i, '\1ices'],
|
|
17
|
+
[/(x|ch|ss|sh|zz)\z/i, '\1es'],
|
|
18
|
+
[/([^aeiouy]|qu)y\z/i, '\1ies'],
|
|
19
|
+
[/(?:([^f])fe|([lr])f)\z/i, '\1\2ves'],
|
|
20
|
+
[/sis\z/i, "ses"],
|
|
21
|
+
[/([ti])um\z/i, '\1a'],
|
|
22
|
+
[/(buffal|tomat|potat|her|ech)o\z/i, '\1oes'],
|
|
23
|
+
[/(bu|mis|ga|alia|statu|stat|vir|octop|cact)us\z/i, '\1uses'],
|
|
24
|
+
[/s\z/i, "s"],
|
|
25
|
+
[/\z/, "s"]
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
SINGULARS = [
|
|
29
|
+
[/(quiz)zes\z/i, '\1'],
|
|
30
|
+
[/(matr)ices\z/i, '\1ix'],
|
|
31
|
+
[/(vert|ind)ices\z/i, '\1ex'],
|
|
32
|
+
[/\A(ox)en/i, '\1'],
|
|
33
|
+
[/(alias|status|bus|campus|virus|octopus|cactus)(es)?\z/i, '\1'],
|
|
34
|
+
[/(x|ch|ss|sh|zz)es\z/i, '\1'],
|
|
35
|
+
[/(m)ovies\z/i, '\1ovie'],
|
|
36
|
+
[/([^aeiouy]|qu)ies\z/i, '\1y'],
|
|
37
|
+
[/([lr])ves\z/i, '\1f'],
|
|
38
|
+
[/([^f])ves\z/i, '\1fe'],
|
|
39
|
+
[/(analy|ba|diagno|parenthe|progno|synop|the)ses\z/i, '\1sis'],
|
|
40
|
+
[/(buffal|tomat|potat|her|ech)oes\z/i, '\1o'],
|
|
41
|
+
[/([ti])a\z/i, '\1um'],
|
|
42
|
+
[/ss\z/i, "ss"],
|
|
43
|
+
[/s\z/i, ""]
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
DEFAULT_IRREGULARS = {
|
|
47
|
+
"person" => "people", "man" => "men", "woman" => "women", "child" => "children",
|
|
48
|
+
"mouse" => "mice", "goose" => "geese", "tooth" => "teeth", "foot" => "feet"
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
DEFAULT_UNCOUNTABLES = %w[equipment information rice money species series fish sheep deer news data].freeze
|
|
52
|
+
|
|
53
|
+
@irregulars = DEFAULT_IRREGULARS.dup
|
|
54
|
+
@uncountables = DEFAULT_UNCOUNTABLES.dup
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
def irregular(singular, plural)
|
|
58
|
+
@irregulars[singular.downcase] = plural.downcase
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def uncountable(*words)
|
|
62
|
+
@uncountables.concat(words.flatten.map(&:downcase))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pluralize(word)
|
|
66
|
+
inflect(word.to_s, @irregulars, PLURALS)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def singularize(word)
|
|
70
|
+
inflect(word.to_s, @irregulars.invert, SINGULARS)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# "inventory_item" / "inventory-item" => "InventoryItem";
|
|
74
|
+
# "admin/products" => "Admin::Products"
|
|
75
|
+
def camelize(term)
|
|
76
|
+
term.to_s.split("/").map do |part|
|
|
77
|
+
part.split(/[_-]/).map { |w| w[0] ? w[0].upcase + w[1..] : w }.join
|
|
78
|
+
end.join("::")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# "InventoryItem" => "inventory_item"; "Admin::Products" => "admin/products"
|
|
82
|
+
def underscore(term)
|
|
83
|
+
term.to_s.gsub("::", "/")
|
|
84
|
+
.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
85
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
86
|
+
.tr("-", "_")
|
|
87
|
+
.downcase
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def dasherize(term) = underscore(term).tr("_", "-")
|
|
91
|
+
|
|
92
|
+
# "inventory_item" => "Inventory item"
|
|
93
|
+
def humanize(term)
|
|
94
|
+
words = underscore(term).delete_suffix("_id").tr("_", " ")
|
|
95
|
+
words[0] ? words[0].upcase + words[1..] : words
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# "inventory_items" => "InventoryItem"
|
|
99
|
+
def classify(term) = camelize(singularize(term.to_s))
|
|
100
|
+
|
|
101
|
+
# "InventoryItem" => "inventory_items"
|
|
102
|
+
def tableize(term) = pluralize(underscore(term))
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# irregulars maps source form => target form (e.g. "person" => "people").
|
|
107
|
+
def inflect(word, irregulars, rules)
|
|
108
|
+
return word if word.empty?
|
|
109
|
+
|
|
110
|
+
prefix, last = split_last_word(word)
|
|
111
|
+
lower = last.downcase
|
|
112
|
+
return word if @uncountables.include?(lower) || irregulars.value?(lower)
|
|
113
|
+
return prefix + match_case(last, irregulars[lower]) if irregulars.key?(lower)
|
|
114
|
+
|
|
115
|
+
rules.each do |pattern, replacement|
|
|
116
|
+
return prefix + last.sub(pattern, replacement) if last.match?(pattern)
|
|
117
|
+
end
|
|
118
|
+
word
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Only the final word is inflected: "line_item" => ["line_", "item"],
|
|
122
|
+
# "LineItem" => ["Line", "Item"].
|
|
123
|
+
def split_last_word(word)
|
|
124
|
+
match = word.match(/\A(.*[_\-\s])([^_\-\s]+)\z/m) || word.match(/\A(.*[a-z\d])([A-Z][^A-Z]*)\z/)
|
|
125
|
+
match ? [match[1], match[2]] : ["", word]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def match_case(original, replacement)
|
|
129
|
+
original[0] == original[0].upcase ? replacement[0].upcase + replacement[1..] : replacement
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
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,188 @@
|
|
|
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), for PostgreSQL, MySQL and
|
|
13
|
+
# SQLite. Workers claim rows one at a time (FOR UPDATE SKIP LOCKED where the
|
|
14
|
+
# database has it); finished jobs are deleted, exhausted ones keep failed_at.
|
|
15
|
+
Sequel.migration do
|
|
16
|
+
up do
|
|
17
|
+
partial = database_type != :mysql # MySQL has no partial indexes
|
|
18
|
+
ready = partial ? { where: Sequel.lit("failed_at IS NULL AND locked_at IS NULL") } : {}
|
|
19
|
+
create_table(:gemstack_jobs) do
|
|
20
|
+
primary_key :id, type: :Bignum
|
|
21
|
+
String :queue, null: false, default: "default"
|
|
22
|
+
Integer :priority, null: false, default: 100
|
|
23
|
+
String :job_class, null: false
|
|
24
|
+
column :args, :jsonb, null: false # json on MySQL and SQLite
|
|
25
|
+
column :run_at, :timestamptz, null: false
|
|
26
|
+
Integer :attempts, null: false, default: 0
|
|
27
|
+
String :last_error, text: true
|
|
28
|
+
column :locked_at, :timestamptz
|
|
29
|
+
String :locked_by
|
|
30
|
+
column :failed_at, :timestamptz
|
|
31
|
+
column :created_at, :timestamptz, null: false
|
|
32
|
+
|
|
33
|
+
# The fetch query: ready jobs by queue, in priority/run_at order.
|
|
34
|
+
index %i[queue priority run_at id], name: :gemstack_jobs_ready, **ready
|
|
35
|
+
index :locked_at, name: :gemstack_jobs_locked
|
|
36
|
+
index :failed_at, name: :gemstack_jobs_failed
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
down do
|
|
41
|
+
drop_table(:gemstack_jobs)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
RUBY
|
|
45
|
+
|
|
46
|
+
def self.apply(db, direction = :up)
|
|
47
|
+
Sequel.extension :migration
|
|
48
|
+
eval(SOURCE, TOPLEVEL_BINDING, "gemstack_jobs_migration.rb").apply(db, direction) # rubocop:disable Security/Eval
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module Adapters
|
|
53
|
+
# The default adapter: jobs are rows in the application's database
|
|
54
|
+
# — PostgreSQL, MySQL or SQLite.
|
|
55
|
+
#
|
|
56
|
+
# - Enqueueing is an INSERT on the current connection, so inside
|
|
57
|
+
# GemStack.transaction a job exists only if the transaction commits.
|
|
58
|
+
# - Workers claim one job at a time in a short transaction: with
|
|
59
|
+
# FOR UPDATE SKIP LOCKED on PostgreSQL and MySQL 8, so workers never
|
|
60
|
+
# block each other; on SQLite by taking the write lock up front.
|
|
61
|
+
# - PostgreSQL also NOTIFYs idle workers at once; elsewhere (and as a
|
|
62
|
+
# safety net) workers poll every config.jobs.poll_interval.
|
|
63
|
+
# - Times are set from Ruby, in UTC, so the database clock and time zone
|
|
64
|
+
# never matter.
|
|
65
|
+
class Database
|
|
66
|
+
CHANNEL = "gemstack_jobs"
|
|
67
|
+
|
|
68
|
+
def initialize(db: nil, table: Jobs.config.table)
|
|
69
|
+
@db = db
|
|
70
|
+
@table = table
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def db
|
|
74
|
+
@db || begin
|
|
75
|
+
require "gemstack/db"
|
|
76
|
+
GemStack::DB.connection
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def dataset = db[@table]
|
|
81
|
+
def postgres? = db.database_type == :postgres
|
|
82
|
+
|
|
83
|
+
def enqueue(payload)
|
|
84
|
+
now = Time.now
|
|
85
|
+
args = postgres? ? Sequel.pg_jsonb_wrap(payload["args"]) : JSON.generate(payload["args"])
|
|
86
|
+
id = dataset.insert(job_class: payload["job_class"], queue: payload["queue"], priority: payload["priority"],
|
|
87
|
+
args: args, run_at: payload["run_at"] || now, created_at: now)
|
|
88
|
+
db.notify(CHANNEL, payload: payload["queue"]) if postgres?
|
|
89
|
+
id
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Claims the next ready job for these queues ("*" = all). Returns a
|
|
93
|
+
# payload Hash or nil.
|
|
94
|
+
def claim(queues, worker)
|
|
95
|
+
now = Time.now
|
|
96
|
+
db.transaction(**claim_options) do
|
|
97
|
+
ready = dataset.where(failed_at: nil, locked_at: nil).where { run_at <= now }
|
|
98
|
+
ready = ready.where(queue: queues) unless queues.include?("*")
|
|
99
|
+
ready = ready.order(:priority, :run_at, :id).limit(1)
|
|
100
|
+
ready = ready.for_update.skip_locked if ready.supports_skip_locked?
|
|
101
|
+
row = ready.select(*returned_columns).first
|
|
102
|
+
dataset.where(id: row[:id]).update(locked_at: now, locked_by: worker) if row
|
|
103
|
+
row && payload_for(row)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def complete(id) = dataset.where(id: id).delete
|
|
108
|
+
|
|
109
|
+
def reschedule(id, run_at:, attempts:, error:)
|
|
110
|
+
dataset.where(id: id).update(locked_at: nil, locked_by: nil, run_at: run_at, attempts: attempts,
|
|
111
|
+
last_error: describe(error))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def fail(id, attempts:, error:)
|
|
115
|
+
return complete(id) unless Jobs.config.keep_failed
|
|
116
|
+
|
|
117
|
+
dataset.where(id: id).update(locked_at: nil, locked_by: nil, failed_at: Time.now,
|
|
118
|
+
attempts: attempts, last_error: describe(error))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Releases this worker's claimed jobs (graceful shutdown).
|
|
122
|
+
def release(worker) = dataset.where(locked_by: worker).update(locked_at: nil, locked_by: nil)
|
|
123
|
+
|
|
124
|
+
# Releases jobs locked longer than `timeout` seconds (their worker died).
|
|
125
|
+
def release_stale(timeout)
|
|
126
|
+
cutoff = Time.now - Float(timeout)
|
|
127
|
+
dataset.where { locked_at < cutoff }.update(locked_at: nil, locked_by: nil)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Failed jobs back to the queue: all, or the given ids.
|
|
131
|
+
def retry_failed(ids = nil)
|
|
132
|
+
failed = dataset.exclude(failed_at: nil)
|
|
133
|
+
failed = failed.where(id: ids) if ids
|
|
134
|
+
failed.update(failed_at: nil, attempts: 0, run_at: Time.now, last_error: nil)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def discard_failed(ids = nil)
|
|
138
|
+
failed = dataset.exclude(failed_at: nil)
|
|
139
|
+
failed = failed.where(id: ids) if ids
|
|
140
|
+
failed.delete
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def failed(limit: 20)
|
|
144
|
+
dataset.exclude(failed_at: nil).order(Sequel.desc(:failed_at)).limit(limit)
|
|
145
|
+
.select(:id, :queue, :job_class, :attempts, :failed_at, :last_error).all
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# { "default" => { ready:, scheduled:, running:, failed: }, ... }
|
|
149
|
+
def stats
|
|
150
|
+
now = Time.now
|
|
151
|
+
states = Sequel.case(
|
|
152
|
+
[[Sequel.~(failed_at: nil), "failed"], [Sequel.~(locked_at: nil), "running"],
|
|
153
|
+
[Sequel[:run_at] > now, "scheduled"]], "ready"
|
|
154
|
+
)
|
|
155
|
+
dataset.group_and_count(:queue, states.as(:state)).all.each_with_object({}) do |row, result|
|
|
156
|
+
(result[row[:queue]] ||= { ready: 0, scheduled: 0, running: 0, failed: 0 })[row[:state].to_sym] =
|
|
157
|
+
row[:count]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# SQLite: take the write lock when the transaction starts, so two
|
|
164
|
+
# workers can't both read the same ready row.
|
|
165
|
+
def claim_options = db.database_type == :sqlite ? { mode: :immediate } : {}
|
|
166
|
+
|
|
167
|
+
# args as text, parsed with the json gem: jobs receive plain Hash/Array
|
|
168
|
+
# values rather than Sequel's JSONB wrappers. (Lazy: Sequel may not be loaded.)
|
|
169
|
+
def returned_columns
|
|
170
|
+
@returned_columns ||= [:id, :job_class, :queue, :priority, :attempts,
|
|
171
|
+
postgres? ? Sequel.cast(:args, :text).as(:args_json) : Sequel.as(:args, :args_json)]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def payload_for(row)
|
|
175
|
+
{ "id" => row[:id], "job_class" => row[:job_class], "queue" => row[:queue],
|
|
176
|
+
"priority" => row[:priority], "args" => JSON.parse(row[:args_json].to_s), "attempts" => row[:attempts] }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def describe(error)
|
|
180
|
+
"#{error.class}: #{error.message}\n#{Array(error.backtrace).first(20).join("\n")}"[0, 10_000]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The adapter's name before it supported MySQL and SQLite; `adapter: :postgres` still works.
|
|
185
|
+
Postgres = Database
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
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 loaded), 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,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
|