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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. 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