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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -28
- data/MIT-LICENSE +16 -17
- data/README.md +452 -388
- data/app/assets/images/dispatch_policy/logo-large.svg +9 -0
- data/app/assets/images/dispatch_policy/logo-small.svg +7 -0
- data/app/assets/javascripts/dispatch_policy/turbo.es2017-umd.min.js +35 -0
- data/app/assets/stylesheets/dispatch_policy/application.css +294 -0
- data/app/controllers/dispatch_policy/application_controller.rb +45 -1
- data/app/controllers/dispatch_policy/assets_controller.rb +31 -0
- data/app/controllers/dispatch_policy/dashboard_controller.rb +91 -0
- data/app/controllers/dispatch_policy/partitions_controller.rb +122 -0
- data/app/controllers/dispatch_policy/policies_controller.rb +94 -267
- data/app/controllers/dispatch_policy/staged_jobs_controller.rb +9 -0
- data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +11 -81
- data/app/models/dispatch_policy/inflight_job.rb +12 -0
- data/app/models/dispatch_policy/partition.rb +21 -0
- data/app/models/dispatch_policy/staged_job.rb +4 -97
- data/app/models/dispatch_policy/tick_sample.rb +11 -0
- data/app/views/dispatch_policy/dashboard/index.html.erb +109 -0
- data/app/views/dispatch_policy/partitions/index.html.erb +63 -0
- data/app/views/dispatch_policy/partitions/show.html.erb +106 -0
- data/app/views/dispatch_policy/policies/index.html.erb +15 -37
- data/app/views/dispatch_policy/policies/show.html.erb +139 -223
- data/app/views/dispatch_policy/shared/_capacity.html.erb +67 -0
- data/app/views/dispatch_policy/shared/_hints.html.erb +13 -0
- data/app/views/dispatch_policy/shared/_partition_row.html.erb +12 -0
- data/app/views/dispatch_policy/staged_jobs/show.html.erb +31 -0
- data/app/views/layouts/dispatch_policy/application.html.erb +164 -231
- data/config/routes.rb +21 -2
- data/db/migrate/20260501000001_create_dispatch_policy_tables.rb +103 -0
- data/lib/dispatch_policy/assets.rb +38 -0
- data/lib/dispatch_policy/bypass.rb +23 -0
- data/lib/dispatch_policy/config.rb +85 -0
- data/lib/dispatch_policy/context.rb +50 -0
- data/lib/dispatch_policy/cursor_pagination.rb +121 -0
- data/lib/dispatch_policy/decision.rb +22 -0
- data/lib/dispatch_policy/engine.rb +5 -27
- data/lib/dispatch_policy/forwarder.rb +63 -0
- data/lib/dispatch_policy/gate.rb +10 -38
- data/lib/dispatch_policy/gates/adaptive_concurrency.rb +99 -97
- data/lib/dispatch_policy/gates/concurrency.rb +45 -26
- data/lib/dispatch_policy/gates/throttle.rb +65 -41
- data/lib/dispatch_policy/inflight_tracker.rb +174 -0
- data/lib/dispatch_policy/job_extension.rb +155 -0
- data/lib/dispatch_policy/operator_hints.rb +126 -0
- data/lib/dispatch_policy/pipeline.rb +48 -0
- data/lib/dispatch_policy/policy.rb +61 -59
- data/lib/dispatch_policy/policy_dsl.rb +120 -0
- data/lib/dispatch_policy/railtie.rb +35 -0
- data/lib/dispatch_policy/registry.rb +46 -0
- data/lib/dispatch_policy/repository.rb +723 -0
- data/lib/dispatch_policy/serializer.rb +36 -0
- data/lib/dispatch_policy/tick.rb +260 -256
- data/lib/dispatch_policy/tick_loop.rb +59 -26
- data/lib/dispatch_policy/version.rb +1 -1
- data/lib/dispatch_policy.rb +72 -52
- data/lib/generators/dispatch_policy/install/install_generator.rb +70 -0
- data/lib/generators/dispatch_policy/install/templates/create_dispatch_policy_tables.rb.tt +95 -0
- data/lib/generators/dispatch_policy/install/templates/dispatch_tick_loop_job.rb.tt +53 -0
- data/lib/generators/dispatch_policy/install/templates/initializer.rb.tt +11 -0
- metadata +134 -42
- data/app/models/dispatch_policy/partition_inflight_count.rb +0 -42
- data/app/models/dispatch_policy/partition_observation.rb +0 -76
- data/app/models/dispatch_policy/throttle_bucket.rb +0 -41
- data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +0 -80
- data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +0 -22
- data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +0 -25
- data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +0 -32
- data/db/migrate/20260425000001_add_duration_to_partition_observations.rb +0 -8
- data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +0 -32
- data/lib/dispatch_policy/dispatch_context.rb +0 -53
- data/lib/dispatch_policy/dispatchable.rb +0 -123
- data/lib/dispatch_policy/gates/fair_interleave.rb +0 -32
- 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
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
48
|
+
iteration += 1
|
|
49
|
+
if (iteration % config.sweep_every_ticks).zero?
|
|
50
|
+
sweep!
|
|
51
|
+
end
|
|
27
52
|
|
|
28
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
data/lib/dispatch_policy.rb
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
47
|
+
def configure
|
|
42
48
|
yield config
|
|
43
49
|
end
|
|
44
50
|
|
|
45
|
-
def
|
|
46
|
-
config
|
|
51
|
+
def config
|
|
52
|
+
@config ||= Config.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def reset_config!
|
|
56
|
+
@config = Config.new
|
|
47
57
|
end
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@registry ||= {}
|
|
59
|
+
def registry
|
|
60
|
+
@registry ||= Registry.new
|
|
52
61
|
end
|
|
53
62
|
|
|
54
|
-
def
|
|
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
|
-
|
|
60
|
-
|
|
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
|