dispatch_policy 0.2.0 → 0.4.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -28
  3. data/MIT-LICENSE +16 -17
  4. data/README.md +452 -388
  5. data/app/assets/images/dispatch_policy/logo-large.svg +9 -0
  6. data/app/assets/images/dispatch_policy/logo-small.svg +7 -0
  7. data/app/assets/javascripts/dispatch_policy/turbo.es2017-umd.min.js +35 -0
  8. data/app/assets/stylesheets/dispatch_policy/application.css +294 -0
  9. data/app/controllers/dispatch_policy/application_controller.rb +45 -1
  10. data/app/controllers/dispatch_policy/assets_controller.rb +31 -0
  11. data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
  12. data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
  13. data/app/controllers/dispatch_policy/policies_controller.rb +94 -267
  14. data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
  15. data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
  16. data/app/models/dispatch_policy/inflight_job.rb +12 -0
  17. data/app/models/dispatch_policy/partition.rb +21 -0
  18. data/app/models/dispatch_policy/staged_job.rb +4 -97
  19. data/app/models/dispatch_policy/tick_sample.rb +11 -0
  20. data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
  21. data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
  22. data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
  23. data/app/views/dispatch_policy/policies/index.html.erb +15 -37
  24. data/app/views/dispatch_policy/policies/show.html.erb +139 -223
  25. data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
  26. data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
  27. data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
  28. data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
  29. data/app/views/layouts/dispatch_policy/application.html.erb +164 -231
  30. data/config/routes.rb +21 -2
  31. data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
  32. data/lib/dispatch_policy/assets.rb +38 -0
  33. data/lib/dispatch_policy/bypass.rb +23 -0
  34. data/lib/dispatch_policy/config.rb +85 -0
  35. data/lib/dispatch_policy/context.rb +50 -0
  36. data/lib/dispatch_policy/cursor_pagination.rb +121 -0
  37. data/lib/dispatch_policy/decision.rb +22 -0
  38. data/lib/dispatch_policy/engine.rb +5 -27
  39. data/lib/dispatch_policy/forwarder.rb +63 -0
  40. data/lib/dispatch_policy/gate.rb +10 -38
  41. data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
  42. data/lib/dispatch_policy/gates/concurrency.rb +45 -26
  43. data/lib/dispatch_policy/gates/throttle.rb +65 -41
  44. data/lib/dispatch_policy/inflight_tracker.rb +174 -0
  45. data/lib/dispatch_policy/job_extension.rb +155 -0
  46. data/lib/dispatch_policy/operator_hints.rb +126 -0
  47. data/lib/dispatch_policy/pipeline.rb +48 -0
  48. data/lib/dispatch_policy/policy.rb +61 -59
  49. data/lib/dispatch_policy/policy_dsl.rb +120 -0
  50. data/lib/dispatch_policy/railtie.rb +35 -0
  51. data/lib/dispatch_policy/registry.rb +46 -0
  52. data/lib/dispatch_policy/repository.rb +723 -0
  53. data/lib/dispatch_policy/serializer.rb +36 -0
  54. data/lib/dispatch_policy/tick.rb +260 -256
  55. data/lib/dispatch_policy/tick_loop.rb +59 -26
  56. data/lib/dispatch_policy/version.rb +1 -1
  57. data/lib/dispatch_policy.rb +72 -52
  58. data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
  59. data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
  60. data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
  61. data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
  62. metadata +134 -42
  63. data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
  64. data/app/models/dispatch_policy/partition_observation.rb +0 -76
  65. data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
  66. data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
  67. data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
  68. data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
  69. data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
  70. data/db/migrate/20260425000001_add_duration_to_partition_observations.rb +0 -8
  71. data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
  72. data/lib/dispatch_policy/dispatch_context.rb +0 -53
  73. data/lib/dispatch_policy/dispatchable.rb +0 -123
  74. data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
  75. data/lib/dispatch_policy/gates/global_cap.rb +0 -26
@@ -1,45 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DispatchPolicy
4
- # Shared driver for DispatchTickLoopJob and any foreground tick (e.g. a
5
- # rake task). Loops Tick.reap + Tick.run with an interruptible sleep and
6
- # bails when stop_when returns true.
7
- class TickLoop
8
- def self.run(policy_name: nil, sleep_for: nil, sleep_for_busy: nil, stop_when: -> { false })
9
- idle_sleep = (sleep_for || DispatchPolicy.config.tick_sleep).to_f
10
- busy_sleep = (sleep_for_busy || DispatchPolicy.config.tick_sleep_busy).to_f
4
+ # Drives admission until `stop_when` fires (deadline, shutdown signal, etc).
5
+ # Runs one Tick per policy per loop iteration; sleeps `idle_pause` when no
6
+ # jobs were admitted across all policies. Periodically (every
7
+ # `sweep_every_ticks` iterations) sweeps stale inflight rows and inactive
8
+ # partitions.
9
+ module TickLoop
10
+ module_function
11
+
12
+ # @param policy_name [String, nil] limit to one policy. nil = all registered.
13
+ # @param shard [String, nil] limit to one shard. nil = all shards.
14
+ def run(policy_name: nil, shard: nil, stop_when: -> { false })
15
+ config = DispatchPolicy.config
16
+ logger = config.logger
17
+ iteration = 0
11
18
 
12
19
  loop do
13
20
  break if stop_when.call
14
21
 
22
+ unless DispatchPolicy.config.enabled
23
+ # Master switch off: stop polling. The job that drives
24
+ # TickLoop.run will re-schedule itself; we exit cleanly so
25
+ # the next iteration sees the flag and stops too.
26
+ logger&.info("[dispatch_policy] TickLoop exiting because config.enabled = false")
27
+ break
28
+ end
29
+
30
+ names = policy_names(policy_name)
31
+ if names.empty?
32
+ sleep(config.idle_pause)
33
+ next
34
+ end
35
+
15
36
  admitted = 0
16
- begin
17
- ActiveRecord::Base.uncached do
18
- Tick.reap
19
- admitted = Tick.run(policy_name: policy_name).to_i
37
+ names.each do |name|
38
+ break if stop_when.call
39
+
40
+ begin
41
+ result = Tick.run(policy_name: name, shard: shard)
42
+ admitted += result.jobs_admitted
43
+ rescue StandardError => e
44
+ logger&.error("[dispatch_policy] tick error policy=#{name} shard=#{shard.inspect} #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
20
45
  end
21
- rescue StandardError => e
22
- Rails.logger&.error("[DispatchPolicy] tick error: #{e.class}: #{e.message}")
23
- Rails.error.report(e, handled: true) if defined?(Rails) && Rails.respond_to?(:error)
24
46
  end
25
47
 
26
- break if stop_when.call
48
+ iteration += 1
49
+ if (iteration % config.sweep_every_ticks).zero?
50
+ sweep!
51
+ end
27
52
 
28
- interruptible_sleep(admitted.positive? ? busy_sleep : idle_sleep, stop_when)
53
+ if admitted.zero?
54
+ sleep(config.idle_pause)
55
+ elsif config.busy_pause.to_f.positive?
56
+ sleep(config.busy_pause)
57
+ end
29
58
  end
30
59
  end
31
60
 
32
- def self.interruptible_sleep(total, stop_when)
33
- return unless total.positive?
34
-
35
- remaining = total
36
- step = 0.1
37
- while remaining.positive?
38
- break if stop_when.call
39
- chunk = [ remaining, step ].min
40
- sleep(chunk)
41
- remaining -= chunk
61
+ def policy_names(filter)
62
+ if filter
63
+ [filter.to_s]
64
+ else
65
+ DispatchPolicy.registry.names
42
66
  end
43
67
  end
68
+
69
+ def sweep!
70
+ cfg = DispatchPolicy.config
71
+ Repository.sweep_stale_inflight!(cutoff_seconds: cfg.inflight_stale_after)
72
+ Repository.sweep_inactive_partitions!(cutoff_seconds: cfg.partition_inactive_after)
73
+ Repository.sweep_old_tick_samples!(cutoff_seconds: cfg.metrics_retention)
74
+ rescue StandardError => e
75
+ DispatchPolicy.config.logger&.error("[dispatch_policy] sweep error: #{e.class}: #{e.message}")
76
+ end
44
77
  end
45
78
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DispatchPolicy
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -1,70 +1,90 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support"
4
+ require "active_support/core_ext"
3
5
  require "active_job"
4
- require "active_record"
5
6
 
6
- require "dispatch_policy/version"
7
- require "dispatch_policy/engine" if defined?(Rails)
7
+ require_relative "dispatch_policy/version"
8
+ require_relative "dispatch_policy/config"
9
+ require_relative "dispatch_policy/context"
10
+ require_relative "dispatch_policy/policy"
11
+ require_relative "dispatch_policy/registry"
12
+ require_relative "dispatch_policy/serializer"
13
+ require_relative "dispatch_policy/bypass"
14
+ require_relative "dispatch_policy/decision"
15
+ require_relative "dispatch_policy/gate"
16
+ require_relative "dispatch_policy/gates/throttle"
17
+ require_relative "dispatch_policy/gates/concurrency"
18
+ require_relative "dispatch_policy/gates/adaptive_concurrency"
19
+ require_relative "dispatch_policy/policy_dsl"
20
+ require_relative "dispatch_policy/cursor_pagination"
21
+ require_relative "dispatch_policy/pipeline"
22
+ require_relative "dispatch_policy/repository"
23
+ require_relative "dispatch_policy/forwarder"
24
+ require_relative "dispatch_policy/inflight_tracker"
25
+ require_relative "dispatch_policy/tick"
26
+ require_relative "dispatch_policy/tick_loop"
27
+ require_relative "dispatch_policy/job_extension"
28
+ require_relative "dispatch_policy/operator_hints"
29
+ require_relative "dispatch_policy/assets"
8
30
 
9
31
  module DispatchPolicy
10
- Config = Struct.new(
11
- :enabled,
12
- :lease_duration,
13
- :batch_size,
14
- :round_robin_quantum,
15
- :tick_max_duration,
16
- :tick_sleep,
17
- :tick_sleep_busy,
18
- :partition_idle_ttl,
19
- :admin_partition_limit,
20
- keyword_init: true
21
- )
32
+ class Error < StandardError; end
33
+ class PolicyAlreadyRegistered < Error; end
34
+ class UnknownGate < Error; end
35
+ class InvalidPolicy < Error; end
36
+ class EnqueueFailed < Error; end
22
37
 
23
- def self.config
24
- @config ||= Config.new(
25
- enabled: true,
26
- lease_duration: 15 * 60, # 15.minutes
27
- batch_size: 500,
28
- round_robin_quantum: 50,
29
- tick_max_duration: 60, # 1.minute
30
- tick_sleep: 1, # idle sleep
31
- tick_sleep_busy: 0.05, # busy sleep
32
- partition_idle_ttl: 30 * 60, # 30.minutes
33
- # Hard cap on rows the admin's partition breakdown will pull per
34
- # aggregation. Protects the host DB and process when a policy has
35
- # tens of thousands of partitions: the admin shows the top-N most
36
- # active and a truncation banner instead of dragging in everything.
37
- admin_partition_limit: 5_000
38
- )
39
- end
38
+ # Adapters whose enqueue runs against ActiveRecord::Base.connection (so
39
+ # the adapter INSERT can join the admission TX) or whose semantics make
40
+ # atomicity moot (test/inline). Substring match against the adapter
41
+ # class name keeps the check resilient to ActiveJob's wrapper renames.
42
+ PG_BACKED_ADAPTER_HINTS = %w[GoodJob SolidQueue].freeze
43
+ EXEMPT_ADAPTER_HINTS = %w[Test Inline Async].freeze
44
+
45
+ module_function
40
46
 
41
- def self.configure
47
+ def configure
42
48
  yield config
43
49
  end
44
50
 
45
- def self.enabled?
46
- config.enabled != false
51
+ def config
52
+ @config ||= Config.new
53
+ end
54
+
55
+ def reset_config!
56
+ @config = Config.new
47
57
  end
48
58
 
49
- # Registry: policy_name => job_class. Populated by Policy#initialize.
50
- def self.registry
51
- @registry ||= {}
59
+ def registry
60
+ @registry ||= Registry.new
52
61
  end
53
62
 
54
- def self.reset_registry!
55
- @registry = {}
63
+ def reset_registry!
64
+ @registry = Registry.new
65
+ end
66
+
67
+ # Logs a warning if the configured ActiveJob adapter is not one of the
68
+ # PG-backed ones the gem can guarantee atomic admission for. We do NOT
69
+ # raise: a host may use a custom PG-backed adapter we don't recognize,
70
+ # or may have accepted the trade-off knowingly. The warning is enough
71
+ # to surface the issue at boot.
72
+ def warn_unsupported_adapter
73
+ return unless defined?(::ActiveJob::Base)
74
+ adapter = ::ActiveJob::Base.queue_adapter
75
+ return unless adapter
76
+
77
+ klass_name = adapter.class.name.to_s
78
+ return if (PG_BACKED_ADAPTER_HINTS + EXEMPT_ADAPTER_HINTS).any? { |hint| klass_name.include?(hint) }
79
+
80
+ config.logger&.warn(
81
+ "[dispatch_policy] active_job adapter is #{klass_name}; atomic admission requires " \
82
+ "a PG-backed adapter that shares ActiveRecord::Base's connection (good_job, solid_queue). " \
83
+ "If the worker process crashes between admission COMMIT and adapter enqueue, the job is lost. " \
84
+ "Set DispatchPolicy.config.database_role if you use a separate DB role for queueing."
85
+ )
56
86
  end
57
87
  end
58
88
 
59
- require "dispatch_policy/policy"
60
- require "dispatch_policy/gate"
61
- require "dispatch_policy/gates/concurrency"
62
- require "dispatch_policy/gates/throttle"
63
- require "dispatch_policy/gates/global_cap"
64
- require "dispatch_policy/gates/fair_interleave"
65
- require "dispatch_policy/gates/adaptive_concurrency"
66
- require "dispatch_policy/dispatch_context"
67
- require "dispatch_policy/dispatchable"
68
- require "dispatch_policy/tick"
69
- require "dispatch_policy/tick_loop"
70
- require "dispatch_policy/active_job_perform_all_later_patch"
89
+ require_relative "dispatch_policy/railtie" if defined?(Rails::Railtie)
90
+ require_relative "dispatch_policy/engine" if defined?(Rails::Engine)
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module DispatchPolicy
7
+ module Generators
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ desc "Installs dispatch_policy: migration, initializer, and tick loop job."
13
+
14
+ def self.next_migration_number(_path)
15
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
16
+ end
17
+
18
+ def copy_migration
19
+ migration_template "create_dispatch_policy_tables.rb.tt",
20
+ "db/migrate/create_dispatch_policy_tables.rb"
21
+ end
22
+
23
+ def create_initializer
24
+ template "initializer.rb.tt", "config/initializers/dispatch_policy.rb"
25
+ end
26
+
27
+ def create_tick_loop_job
28
+ template "dispatch_tick_loop_job.rb.tt", "app/jobs/dispatch_tick_loop_job.rb"
29
+ end
30
+
31
+ def show_readme
32
+ readme_text = <<~MSG
33
+
34
+ dispatch_policy installed.
35
+
36
+ Next steps:
37
+ 1) bin/rails db:migrate
38
+ 2) Mount the engine in config/routes.rb:
39
+ mount DispatchPolicy::Engine, at: "/dispatch_policy"
40
+ 3) Schedule DispatchTickLoopJob (cron / good_job recurring / solid_queue recurring)
41
+ and start it once: DispatchTickLoopJob.perform_later
42
+ 4) Declare a policy in any ActiveJob:
43
+ dispatch_policy :name do
44
+ context ->(args) { { ... } }
45
+ partition_by ->(c) { c[:key] }
46
+ gate :throttle, rate: 10, per: 60
47
+ end
48
+
49
+ MSG
50
+ say readme_text, :green
51
+ end
52
+
53
+ private
54
+
55
+ def good_job?
56
+ adapter_name == "good_job"
57
+ end
58
+
59
+ def solid_queue?
60
+ adapter_name == "solid_queue"
61
+ end
62
+
63
+ def adapter_name
64
+ Rails.application.config.active_job.queue_adapter.to_s
65
+ rescue StandardError
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,95 @@
1
+ class CreateDispatchPolicyTables < ActiveRecord::Migration[<%= Rails::VERSION::STRING.to_f %>]
2
+ def change
3
+ create_table :dispatch_policy_staged_jobs do |t|
4
+ t.string :policy_name, null: false
5
+ t.string :partition_key, null: false
6
+ t.string :queue_name
7
+ t.string :job_class, null: false
8
+ t.jsonb :job_data, null: false
9
+ t.datetime :scheduled_at
10
+ t.integer :priority, default: 0, null: false
11
+ t.datetime :enqueued_at, null: false, default: -> { "now()" }
12
+ t.jsonb :context, null: false, default: {}
13
+ end
14
+ add_index :dispatch_policy_staged_jobs,
15
+ [:policy_name, :partition_key, :scheduled_at, :id],
16
+ name: "idx_dp_staged_admission",
17
+ order: { scheduled_at: "ASC NULLS FIRST", id: :asc }
18
+ add_index :dispatch_policy_staged_jobs, :enqueued_at,
19
+ name: "idx_dp_staged_enqueued_at"
20
+
21
+ create_table :dispatch_policy_partitions do |t|
22
+ t.string :policy_name, null: false
23
+ t.string :partition_key, null: false
24
+ t.string :queue_name
25
+ t.string :shard, null: false, default: "default"
26
+ t.string :status, null: false, default: "active"
27
+ t.integer :pending_count, null: false, default: 0
28
+ t.bigint :total_admitted, null: false, default: 0
29
+ t.jsonb :context, null: false, default: {}
30
+ t.datetime :context_updated_at
31
+ t.datetime :last_enqueued_at
32
+ t.datetime :last_checked_at
33
+ t.datetime :last_admit_at
34
+ t.datetime :next_eligible_at
35
+ t.jsonb :gate_state, null: false, default: {}
36
+ t.float :decayed_admits, null: false, default: 0.0
37
+ t.datetime :decayed_admits_at
38
+ t.timestamps
39
+ end
40
+ add_index :dispatch_policy_partitions,
41
+ [:policy_name, :partition_key],
42
+ unique: true, name: "idx_dp_partitions_lookup"
43
+ add_index :dispatch_policy_partitions,
44
+ [:policy_name, :shard, :status, :next_eligible_at, :last_checked_at],
45
+ name: "idx_dp_partitions_tick_order",
46
+ order: { next_eligible_at: "ASC NULLS FIRST", last_checked_at: "ASC NULLS FIRST" }
47
+
48
+ create_table :dispatch_policy_inflight_jobs do |t|
49
+ t.string :policy_name, null: false
50
+ t.string :partition_key, null: false
51
+ t.string :active_job_id, null: false
52
+ t.datetime :admitted_at, null: false, default: -> { "now()" }
53
+ t.datetime :heartbeat_at, null: false, default: -> { "now()" }
54
+ end
55
+ add_index :dispatch_policy_inflight_jobs, :active_job_id, unique: true,
56
+ name: "idx_dp_inflight_active_job_id"
57
+ add_index :dispatch_policy_inflight_jobs, [:policy_name, :partition_key],
58
+ name: "idx_dp_inflight_partition"
59
+ add_index :dispatch_policy_inflight_jobs, :heartbeat_at,
60
+ name: "idx_dp_inflight_heartbeat"
61
+
62
+ create_table :dispatch_policy_tick_samples do |t|
63
+ t.string :policy_name, null: false
64
+ t.datetime :sampled_at, null: false, default: -> { "now()" }
65
+ t.integer :duration_ms, null: false, default: 0
66
+ t.integer :partitions_seen, null: false, default: 0
67
+ t.integer :partitions_admitted, null: false, default: 0
68
+ t.integer :partitions_denied, null: false, default: 0
69
+ t.integer :jobs_admitted, null: false, default: 0
70
+ t.integer :forward_failures, null: false, default: 0
71
+ t.integer :pending_total, null: false, default: 0
72
+ t.integer :inflight_total, null: false, default: 0
73
+ t.jsonb :denied_reasons, null: false, default: {}
74
+ end
75
+ add_index :dispatch_policy_tick_samples, [:policy_name, :sampled_at],
76
+ name: "idx_dp_tick_samples_lookup",
77
+ order: { sampled_at: :desc }
78
+ add_index :dispatch_policy_tick_samples, :sampled_at,
79
+ name: "idx_dp_tick_samples_sweep"
80
+
81
+ create_table :dispatch_policy_adaptive_concurrency_stats do |t|
82
+ t.string :policy_name, null: false
83
+ t.string :partition_key, null: false
84
+ t.integer :current_max, null: false
85
+ t.float :ewma_latency_ms, null: false, default: 0.0
86
+ t.integer :sample_count, null: false, default: 0
87
+ t.datetime :last_observed_at
88
+ t.timestamps
89
+ end
90
+ add_index :dispatch_policy_adaptive_concurrency_stats,
91
+ [:policy_name, :partition_key],
92
+ unique: true,
93
+ name: "idx_dp_adaptive_concurrency_lookup"
94
+ end
95
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run one of these per (policy, shard) pair to parallelise admission.
4
+ # When called without arguments, processes every registered policy across
5
+ # every shard from a single worker — fine for small deployments.
6
+ #
7
+ # DispatchTickLoopJob.perform_later # all policies, all shards
8
+ # DispatchTickLoopJob.perform_later("events") # one policy, all shards
9
+ # DispatchTickLoopJob.perform_later("events", "shard-1") # one policy, one shard
10
+ #
11
+ # When a shard argument is provided, the job is also enqueued onto a queue
12
+ # named after the shard so the same worker pool can run both the tick loop
13
+ # and the admitted jobs (assuming the policy's shard_by returns the queue
14
+ # name your jobs use).
15
+ class DispatchTickLoopJob < ApplicationJob
16
+ queue_as { arguments[1].presence || :dispatch_loop }
17
+ <% if good_job? -%>
18
+
19
+ include GoodJob::ActiveJobExtensions::Concurrency
20
+ good_job_control_concurrency_with(
21
+ total_limit: 1,
22
+ key: -> { "dispatch_tick_loop:#{arguments[0] || 'all'}:#{arguments[1] || 'all'}" }
23
+ )
24
+ <% elsif solid_queue? -%>
25
+
26
+ limits_concurrency to: 1,
27
+ key: -> { "dispatch_tick_loop:#{arguments[0] || 'all'}:#{arguments[1] || 'all'}" }
28
+ <% end -%>
29
+
30
+ def perform(policy_name = nil, shard = nil)
31
+ deadline = Time.current + DispatchPolicy.config.tick_max_duration
32
+
33
+ DispatchPolicy::TickLoop.run(
34
+ policy_name: policy_name,
35
+ shard: shard,
36
+ stop_when: -> { adapter_shutting_down? || Time.current >= deadline }
37
+ )
38
+
39
+ self.class.set(wait: 1.second).perform_later(policy_name, shard)
40
+ end
41
+
42
+ private
43
+
44
+ def adapter_shutting_down?
45
+ <% if good_job? -%>
46
+ GoodJob.current_thread_shutting_down?
47
+ <% elsif solid_queue? -%>
48
+ defined?(SolidQueue::Process) && SolidQueue::Process.current_process&.shutdown?
49
+ <% else -%>
50
+ false
51
+ <% end -%>
52
+ end
53
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ DispatchPolicy.configure do |c|
4
+ c.tick_max_duration = 25 # seconds — total time the tick job stays admitting
5
+ c.partition_batch_size = 50 # partitions claimed per tick iteration
6
+ c.admission_batch_size = 100 # max jobs admitted per partition per iteration
7
+ c.idle_pause = 0.5 # seconds slept when no admissions happened
8
+ c.partition_inactive_after = 24 * 60 * 60 # GC partitions idle this long
9
+ c.inflight_stale_after = 5 * 60 # GC inflight rows whose worker stopped heartbeating
10
+ c.sweep_every_ticks = 50 # how often to run the sweepers
11
+ end