dispatch_policy 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/MIT-LICENSE +21 -0
- data/README.md +435 -0
- data/app/controllers/dispatch_policy/application_controller.rb +9 -0
- data/app/controllers/dispatch_policy/policies_controller.rb +269 -0
- data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +89 -0
- data/app/models/dispatch_policy/application_record.rb +7 -0
- data/app/models/dispatch_policy/partition_inflight_count.rb +42 -0
- data/app/models/dispatch_policy/partition_observation.rb +49 -0
- data/app/models/dispatch_policy/staged_job.rb +105 -0
- data/app/models/dispatch_policy/throttle_bucket.rb +41 -0
- data/app/views/dispatch_policy/policies/index.html.erb +52 -0
- data/app/views/dispatch_policy/policies/show.html.erb +241 -0
- data/app/views/layouts/dispatch_policy/application.html.erb +266 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +80 -0
- data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +22 -0
- data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +25 -0
- data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +32 -0
- data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +32 -0
- data/lib/dispatch_policy/dispatch_context.rb +53 -0
- data/lib/dispatch_policy/dispatchable.rb +120 -0
- data/lib/dispatch_policy/engine.rb +36 -0
- data/lib/dispatch_policy/gate.rb +49 -0
- data/lib/dispatch_policy/gates/adaptive_concurrency.rb +123 -0
- data/lib/dispatch_policy/gates/concurrency.rb +43 -0
- data/lib/dispatch_policy/gates/fair_interleave.rb +32 -0
- data/lib/dispatch_policy/gates/global_cap.rb +26 -0
- data/lib/dispatch_policy/gates/throttle.rb +52 -0
- data/lib/dispatch_policy/install_generator.rb +23 -0
- data/lib/dispatch_policy/policy.rb +73 -0
- data/lib/dispatch_policy/tick.rb +214 -0
- data/lib/dispatch_policy/tick_loop.rb +45 -0
- data/lib/dispatch_policy/version.rb +5 -0
- data/lib/dispatch_policy.rb +64 -0
- metadata +182 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 78151d6562bbaa7ef349966a0c968ee26cc3c2c1830cb3f33461c5c1d5f66303
|
|
4
|
+
data.tar.gz: 29360c499ccdab8bb98c865bfcf522efad89facd287ee335beb9eeec302cb5cf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 306526c1343773820a6a1df453a716551201433555843869b841c85ffefaea3b61c64c6b29cec2c7105718ba019f92c3dd19180ccd837260dd65c0b317fb75e5
|
|
7
|
+
data.tar.gz: 8d5372b9feb3857b45ad0745feae75a123fa68a219d936ed6bf9af5eb4c9f97dbc183cae86cb36bf155b65c47137f4c897a90f2f5b85112fc9099397bdf35ca3
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Rails engine + ActiveJob integration (`DispatchPolicy::Dispatchable`).
|
|
8
|
+
- Gates: `:throttle`, `:concurrency`, `:global_cap`, `:fair_interleave`, `:adaptive_concurrency`.
|
|
9
|
+
- Staged jobs with dedupe, round-robin fairness, per-partition counters, and throttle buckets.
|
|
10
|
+
- Admin UI (Chart.js + Turbo) with watched partitions, sparklines, and EWMA queue-lag charts.
|
|
11
|
+
- PostgreSQL required (uses `FOR UPDATE SKIP LOCKED`, `ON CONFLICT`, and `jsonb`).
|
|
12
|
+
- Experimental — being trialed on pulso.run.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 José Galisteo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# DispatchPolicy
|
|
2
|
+
|
|
3
|
+
> **⚠️ Experimental.** The API, schema, and defaults can change between
|
|
4
|
+
> minor releases without notice. DispatchPolicy is currently running in
|
|
5
|
+
> production on [pulso.run](https://pulso.run) — that's how we learn
|
|
6
|
+
> what breaks. If you pick it up for your own project, pin the exact
|
|
7
|
+
> version and expect to follow the changelog.
|
|
8
|
+
>
|
|
9
|
+
> **PostgreSQL only (11+).** The staging, admission, and fairness
|
|
10
|
+
> machinery lean on `jsonb`, partial indexes, `FOR UPDATE SKIP LOCKED`,
|
|
11
|
+
> `ON CONFLICT`, and `CROSS JOIN LATERAL`. MySQL/SQLite support isn't
|
|
12
|
+
> closed off as a goal — being drop-in across every ActiveJob backend
|
|
13
|
+
> is the long-term direction — but it would take meaningful rework
|
|
14
|
+
> (shadow columns for `jsonb`, full indexes instead of partial, a
|
|
15
|
+
> different batch-fetch strategy for fairness). Contributions welcome.
|
|
16
|
+
|
|
17
|
+
Per-partition admission control for ActiveJob. Stages `perform_later`
|
|
18
|
+
into a dedicated table, runs a tick loop that admits jobs through
|
|
19
|
+
declared gates (throttle, concurrency, global_cap, fair_interleave,
|
|
20
|
+
adaptive_concurrency), then forwards survivors to the real adapter.
|
|
21
|
+
|
|
22
|
+
Use it when you need:
|
|
23
|
+
|
|
24
|
+
- **Per-tenant / per-endpoint throttle** that's exact (token bucket)
|
|
25
|
+
instead of best-effort enqueue-side.
|
|
26
|
+
- **Per-partition concurrency** with a proper release hook on job
|
|
27
|
+
completion (and lease-expiry recovery if the worker dies mid-perform).
|
|
28
|
+
- **Adaptive concurrency** — a cap that shrinks under queue pressure
|
|
29
|
+
and grows back when workers keep up, without manual tuning.
|
|
30
|
+
- **Dedupe** against a partial unique index, not an in-memory key.
|
|
31
|
+
- **Round-robin fairness across tenants** (LATERAL batch fetch) so one
|
|
32
|
+
tenant's burst can't starve the others.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Add to your `Gemfile`:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
gem "dispatch_policy"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Copy the migration and run it:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
bundle exec rails dispatch_policy:install:migrations
|
|
46
|
+
bundle exec rails db:migrate
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Mount the admin UI in `config/routes.rb` (optional):
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
mount DispatchPolicy::Engine => "/admin/dispatch_policy"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Configure in `config/initializers/dispatch_policy.rb`:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
DispatchPolicy.configure do |c|
|
|
59
|
+
c.enabled = ENV.fetch("DISPATCH_POLICY_ENABLED", "true") != "false"
|
|
60
|
+
c.lease_duration = 15.minutes
|
|
61
|
+
c.batch_size = 500
|
|
62
|
+
c.round_robin_quantum = 50
|
|
63
|
+
c.tick_sleep = 1 # idle
|
|
64
|
+
c.tick_sleep_busy = 0.05 # after productive ticks
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Flow
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
ActiveJob#perform_later
|
|
72
|
+
→ Dispatchable#enqueue
|
|
73
|
+
→ StagedJob.stage! (insert into dispatch_policy_staged_jobs, pending)
|
|
74
|
+
|
|
75
|
+
(tick loop, periodically)
|
|
76
|
+
→ SELECT pending FOR UPDATE SKIP LOCKED
|
|
77
|
+
→ Run gates in declared order; survivors are the admitted set
|
|
78
|
+
→ StagedJob#mark_admitted! (increment counters, set admitted_at)
|
|
79
|
+
→ job.enqueue(_bypass_staging: true) (hand off to the real adapter)
|
|
80
|
+
|
|
81
|
+
(worker runs perform)
|
|
82
|
+
→ Dispatchable#around_perform
|
|
83
|
+
→ block.call
|
|
84
|
+
→ release counters, mark StagedJob completed_at, record observation
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Declaring a policy
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class SendWebhookJob < ApplicationJob
|
|
91
|
+
include DispatchPolicy::Dispatchable
|
|
92
|
+
|
|
93
|
+
dispatch_policy do
|
|
94
|
+
# Persisted in the staged row so gates can read it without touching AR.
|
|
95
|
+
context ->(args) {
|
|
96
|
+
event = args.first
|
|
97
|
+
{ endpoint_id: event.endpoint_id, rate_limit: event.endpoint.rate_limit }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Partial unique index dedupes identical keys while the previous is pending.
|
|
101
|
+
dedupe_key ->(args) { "event:#{args.first.id}" }
|
|
102
|
+
|
|
103
|
+
# Tenant fairness — see the "Round-robin" section below.
|
|
104
|
+
round_robin_by ->(args) { args.first.account_id }
|
|
105
|
+
|
|
106
|
+
gate :throttle,
|
|
107
|
+
rate: ->(ctx) { ctx[:rate_limit] },
|
|
108
|
+
per: 1.minute,
|
|
109
|
+
partition_by: ->(ctx) { ctx[:endpoint_id] }
|
|
110
|
+
|
|
111
|
+
gate :fair_interleave
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def perform(event) = event.deliver!
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`perform_later` stages the job; the tick admits it when its gates pass.
|
|
119
|
+
|
|
120
|
+
## Gates
|
|
121
|
+
|
|
122
|
+
Gates run in declared order, each narrowing the survivor set. Any option
|
|
123
|
+
that takes a value can alternatively take a lambda that receives the
|
|
124
|
+
`ctx` hash, so parameters can depend on per-job data.
|
|
125
|
+
|
|
126
|
+
### `:concurrency` — in-flight cap per partition
|
|
127
|
+
|
|
128
|
+
Caps the number of admitted-but-not-yet-completed jobs in each
|
|
129
|
+
partition. Tracks in-flight counts in
|
|
130
|
+
`dispatch_policy_partition_counts`; decremented by the `around_perform`
|
|
131
|
+
hook when the job finishes, or by the reaper when a lease expires
|
|
132
|
+
(worker crashed).
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
gate :concurrency,
|
|
136
|
+
max: ->(ctx) { ctx[:max_per_account] || 5 },
|
|
137
|
+
partition_by: ->(ctx) { "acct:#{ctx[:account_id]}" }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
When to reach for it: external APIs with per-tenant concurrency limits,
|
|
141
|
+
database-heavy jobs you don't want to pile up per customer, anything
|
|
142
|
+
where "at most N running at once for this key" matters.
|
|
143
|
+
|
|
144
|
+
### `:throttle` — token-bucket rate limit per partition
|
|
145
|
+
|
|
146
|
+
Refills `rate` tokens every `per` seconds, capped at `burst` (defaults
|
|
147
|
+
to `rate`). Admits jobs while tokens are available; leaves the rest
|
|
148
|
+
pending for the next tick.
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
gate :throttle,
|
|
152
|
+
rate: 100, # tokens
|
|
153
|
+
per: 1.minute, # refill window
|
|
154
|
+
burst: 100, # bucket cap (optional, defaults to rate)
|
|
155
|
+
partition_by: ->(ctx) { "host:#{ctx[:host]}" }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`rate` and `burst` accept lambdas, so the limit can come from
|
|
159
|
+
configuration stored alongside the thing being rate-limited:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
gate :throttle,
|
|
163
|
+
rate: ->(ctx) { ctx[:rate_limit] },
|
|
164
|
+
per: 1.minute,
|
|
165
|
+
partition_by: ->(ctx) { ctx[:endpoint_id] }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Unlike `:concurrency`, throttle does **not** release tokens on job
|
|
169
|
+
completion — tokens refill only with elapsed time.
|
|
170
|
+
|
|
171
|
+
### `:global_cap` — single cap across all partitions
|
|
172
|
+
|
|
173
|
+
A global version of `:concurrency`: at most `max` jobs admitted
|
|
174
|
+
simultaneously across the whole policy, regardless of partition.
|
|
175
|
+
Useful as a safety ceiling on top of per-partition limits.
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
gate :concurrency, max: 10, partition_by: ->(ctx) { ctx[:tenant] }
|
|
179
|
+
gate :global_cap, max: 200
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Reads: "up to 10 in flight per tenant, but never more than 200 total".
|
|
183
|
+
|
|
184
|
+
### `:fair_interleave` — round-robin ordering across partitions
|
|
185
|
+
|
|
186
|
+
Not a filter — a reordering step. Groups the batch by its primary
|
|
187
|
+
partition and interleaves, so no single partition can starve others
|
|
188
|
+
even if it has many pending jobs.
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
gate :concurrency, max: 10, partition_by: ->(ctx) { "acct:#{ctx[:account_id]}" }
|
|
192
|
+
gate :fair_interleave
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Place it after a gate that assigned partitions; interleaving is keyed
|
|
196
|
+
off the first partition a row picked up.
|
|
197
|
+
|
|
198
|
+
### `:adaptive_concurrency` — per-partition cap that self-tunes
|
|
199
|
+
|
|
200
|
+
The cap per partition (`current_max`) shrinks when the adapter queue
|
|
201
|
+
backs up (EWMA of queue lag > `target_lag_ms`) or when performs raise;
|
|
202
|
+
grows back by +1 when lag stays under target. AIMD loop on a
|
|
203
|
+
per-partition stats row (`dispatch_policy_adaptive_concurrency_stats`).
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
gate :adaptive_concurrency,
|
|
207
|
+
partition_by: ->(ctx) { ctx[:account_id] },
|
|
208
|
+
initial_max: 3,
|
|
209
|
+
target_lag_ms: 1000, # acceptable queue wait before admission
|
|
210
|
+
min: 1 # floor so a partition can't lock out
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- **Feedback signal**: `admitted_at → perform_start` (queue wait in the
|
|
215
|
+
real adapter). Pure saturation signal — slow performs in the
|
|
216
|
+
downstream service don't punish admissions if workers still drain
|
|
217
|
+
the queue quickly.
|
|
218
|
+
- **Growth**: +1 per fast success. No hard ceiling; the algorithm
|
|
219
|
+
self-limits via `target_lag_ms`. If the queue builds up, the cap
|
|
220
|
+
shrinks multiplicatively.
|
|
221
|
+
- **Failure**: `current_max *= 0.5` (halve) when `perform` raises.
|
|
222
|
+
- **Slow**: `current_max *= 0.95` when EWMA lag > target.
|
|
223
|
+
|
|
224
|
+
### Choosing `target_lag_ms`
|
|
225
|
+
|
|
226
|
+
It's the knob that trades latency for throughput. Rough guide:
|
|
227
|
+
|
|
228
|
+
- **Too low** (e.g. 10-50 ms). The gate reacts to every tiny bump in
|
|
229
|
+
queue wait and shrinks the cap aggressively. Workers can end up
|
|
230
|
+
idle with jobs still pending admission because the cap is
|
|
231
|
+
overcorrecting — classic contention / overshoot.
|
|
232
|
+
- **Too high** (e.g. 30 s). The gate barely ever pushes back, so
|
|
233
|
+
you get near-maximum throughput at the cost of real queue buildup;
|
|
234
|
+
newly admitted jobs may wait seconds before a worker picks them
|
|
235
|
+
up.
|
|
236
|
+
- **Reasonable starting point**: `≈ worker_max_threads × avg_perform_ms`.
|
|
237
|
+
If you run 5 workers at ~200 ms/perform, `target_lag_ms: 1000`
|
|
238
|
+
means "it's OK if the adapter queue stays at most ~1 second
|
|
239
|
+
deep". You'll want to tune from there based on what your
|
|
240
|
+
downstream tolerates and how fast you want bursts to drain.
|
|
241
|
+
|
|
242
|
+
Pair it with `round_robin_by` for multi-tenant systems that want
|
|
243
|
+
automatic backpressure without hand-tuned caps per tenant:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
round_robin_by ->(args) { args.first[:account_id] }
|
|
247
|
+
gate :adaptive_concurrency,
|
|
248
|
+
partition_by: ->(ctx) { ctx[:account_id] },
|
|
249
|
+
initial_max: 3,
|
|
250
|
+
target_lag_ms: 1000
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Queues and partitioning
|
|
254
|
+
|
|
255
|
+
DispatchPolicy operates at the **policy** (class) level. A job's
|
|
256
|
+
ActiveJob `queue` and `priority` travel through staging into admission
|
|
257
|
+
and on to the real adapter — workers of each queue pick up their jobs
|
|
258
|
+
normally — but neither affects which staged rows the gates see. All
|
|
259
|
+
enqueues of the same job class share one policy, one throttle bucket,
|
|
260
|
+
one concurrency cap.
|
|
261
|
+
|
|
262
|
+
Two consequences to be aware of:
|
|
263
|
+
|
|
264
|
+
- Enqueuing the same job to different queues does **not** give one
|
|
265
|
+
queue priority at admission; they share the policy's gates. If
|
|
266
|
+
urgent work should jump ahead, set a lower ActiveJob `priority`
|
|
267
|
+
(the admission SELECT is `ORDER BY priority, staged_at`) — or split
|
|
268
|
+
into a subclass with its own policy.
|
|
269
|
+
- `dedupe_key` is queue-agnostic: the same key enqueued to
|
|
270
|
+
`:urgent` and `:low` dedupes to one row.
|
|
271
|
+
|
|
272
|
+
### Using queue as a partition
|
|
273
|
+
|
|
274
|
+
The context hash has `queue_name` and `priority` injected automatically
|
|
275
|
+
at stage time (user-supplied keys win). Use them in any `partition_by`:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
class SendEmailJob < ApplicationJob
|
|
279
|
+
include DispatchPolicy::Dispatchable
|
|
280
|
+
|
|
281
|
+
dispatch_policy do
|
|
282
|
+
context ->(args) { { account_id: args.first.account_id } }
|
|
283
|
+
|
|
284
|
+
# Separate throttle bucket per (queue, account) — urgent and default
|
|
285
|
+
# don't share rate tokens.
|
|
286
|
+
gate :throttle,
|
|
287
|
+
rate: 100,
|
|
288
|
+
per: 1.minute,
|
|
289
|
+
partition_by: ->(ctx) { "#{ctx[:queue_name]}:#{ctx[:account_id]}" }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
SendEmailJob.set(queue: :urgent).perform_later(user)
|
|
294
|
+
SendEmailJob.set(queue: :default).perform_later(user)
|
|
295
|
+
# → two partitions, each with its own bucket.
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
If you'd rather keep the two streams fully isolated (separate policies,
|
|
299
|
+
admin rows, and dedupe scopes), subclass:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
class UrgentEmailJob < SendEmailJob
|
|
303
|
+
queue_as :urgent
|
|
304
|
+
dispatch_policy do
|
|
305
|
+
context ->(args) { { account_id: args.first.account_id } }
|
|
306
|
+
gate :throttle, rate: 500, per: 1.minute, partition_by: ->(ctx) { ctx[:account_id] }
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Dedupe
|
|
312
|
+
|
|
313
|
+
`dedupe_key` is enforced by a partial unique index on
|
|
314
|
+
`(policy_name, dedupe_key) WHERE completed_at IS NULL`. Semantics:
|
|
315
|
+
|
|
316
|
+
- Re-enqueuing while a previous staged row is pending or admitted →
|
|
317
|
+
silently dropped.
|
|
318
|
+
- Re-enqueuing after the previous completes → fresh staged row.
|
|
319
|
+
- Returning `nil` from the lambda → no dedup for that enqueue.
|
|
320
|
+
|
|
321
|
+
Typical pattern: `"<domain>:<entity>:<id>"` (`"monitor:42"`,
|
|
322
|
+
`"event:abc123"`). Keep it stable for the duration of a logical unit
|
|
323
|
+
of work.
|
|
324
|
+
|
|
325
|
+
## Round-robin batching (tenant fairness)
|
|
326
|
+
|
|
327
|
+
For policies where every tenant should keep making progress even
|
|
328
|
+
when one suddenly enqueues 100× its normal volume, neither throttle
|
|
329
|
+
nor concurrency is a good fit — you want max throughput, just
|
|
330
|
+
fairness. `round_robin_by` solves it at the batch SELECT layer:
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
dispatch_policy do
|
|
334
|
+
context ->(args) { { account_id: args.first.account_id } }
|
|
335
|
+
round_robin_by ->(args) { args.first.account_id }
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
At stage time the lambda's result is written into the dedicated
|
|
340
|
+
`round_robin_key` column (indexed). `Tick.run` then uses a two-phase
|
|
341
|
+
fetch:
|
|
342
|
+
|
|
343
|
+
1. **LATERAL join** — distinct keys × per-key `LIMIT round_robin_quantum`.
|
|
344
|
+
Guarantees each active tenant gets at least `quantum` rows per
|
|
345
|
+
tick, so a tenant with 10 pending is served in the same tick as
|
|
346
|
+
a tenant with 50k pending.
|
|
347
|
+
2. **Top-up** — if the fairness floor doesn't fill `batch_size`, the
|
|
348
|
+
remaining slots go to the oldest pending (excluding the ids
|
|
349
|
+
already locked). Keeps single-tenant throughput at full capacity.
|
|
350
|
+
|
|
351
|
+
Cost per tick is O(`quantum × active_keys`), not O(backlog) — so the
|
|
352
|
+
admin stays snappy even with thousands of distinct tenants.
|
|
353
|
+
|
|
354
|
+
## Running the tick
|
|
355
|
+
|
|
356
|
+
The gem exposes `DispatchPolicy::TickLoop.run(policy_name:, stop_when:)`
|
|
357
|
+
but **does not ship a tick job** — concurrency semantics are
|
|
358
|
+
queue-adapter specific (GoodJob's `total_limit`, Sidekiq Enterprise
|
|
359
|
+
uniqueness, etc.), so you write a small job in your app that wraps
|
|
360
|
+
the loop with whatever dedup your adapter provides. Example for
|
|
361
|
+
GoodJob:
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
# app/jobs/dispatch_tick_loop_job.rb
|
|
365
|
+
class DispatchTickLoopJob < ApplicationJob
|
|
366
|
+
include GoodJob::ActiveJobExtensions::Concurrency
|
|
367
|
+
good_job_control_concurrency_with(
|
|
368
|
+
total_limit: 1,
|
|
369
|
+
key: -> { "dispatch_tick_loop:#{arguments.first || 'all'}" }
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def perform(policy_name = nil)
|
|
373
|
+
deadline = Time.current + DispatchPolicy.config.tick_max_duration
|
|
374
|
+
DispatchPolicy::TickLoop.run(
|
|
375
|
+
policy_name: policy_name,
|
|
376
|
+
stop_when: -> {
|
|
377
|
+
GoodJob.current_thread_shutting_down? || Time.current >= deadline
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
# Self-chain so the next run starts immediately; cron below is a safety net.
|
|
381
|
+
DispatchTickLoopJob.set(wait: 1.second).perform_later(policy_name)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Schedule it (every 10s as a safety net — the self-chain keeps one
|
|
387
|
+
alive under normal operation):
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
# config/application.rb
|
|
391
|
+
config.good_job.cron = {
|
|
392
|
+
dispatch_tick_loop: {
|
|
393
|
+
cron: "*/10 * * * * *",
|
|
394
|
+
class: "DispatchTickLoopJob"
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
For adapters without a first-class dedup mechanism, implement it
|
|
400
|
+
yourself (e.g. `pg_try_advisory_lock` inside `perform`) before calling
|
|
401
|
+
`DispatchPolicy::TickLoop.run`.
|
|
402
|
+
|
|
403
|
+
## Admin UI
|
|
404
|
+
|
|
405
|
+
`DispatchPolicy::Engine` ships a read-only admin mounted wherever
|
|
406
|
+
you like. Features:
|
|
407
|
+
|
|
408
|
+
- Policy index with pending / admitted / completed-24h totals.
|
|
409
|
+
- Per-policy page with a **partition breakdown** (watched + searchable
|
|
410
|
+
list) showing pending-eligible / pending-scheduled / in-flight /
|
|
411
|
+
completed / adaptive cap / EWMA latency / last enqueue / last
|
|
412
|
+
dispatch per partition.
|
|
413
|
+
- Line chart of avg EWMA queue lag (last hour, per minute) with
|
|
414
|
+
completions-per-minute bars behind it.
|
|
415
|
+
- Per-partition sparkline with the same overlay; click to watch /
|
|
416
|
+
unwatch. Watched set is persisted in `localStorage` and synced into
|
|
417
|
+
the URL so reloading keeps your view.
|
|
418
|
+
- Opt-in auto-refresh (off / 2s / 5s / 15s) stored in `localStorage`.
|
|
419
|
+
Page updates via Turbo morph — scroll position and tooltips survive.
|
|
420
|
+
|
|
421
|
+
## Testing
|
|
422
|
+
|
|
423
|
+
```
|
|
424
|
+
bundle install
|
|
425
|
+
bundle exec rake test
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Tests require a PostgreSQL instance (uses `ON CONFLICT`, partial
|
|
429
|
+
indexes, `FOR UPDATE SKIP LOCKED`, `jsonb`). `PGUSER` / `PGHOST` /
|
|
430
|
+
`PGPASSWORD` env vars override the defaults in
|
|
431
|
+
`test/dummy/config/database.yml`.
|
|
432
|
+
|
|
433
|
+
## License
|
|
434
|
+
|
|
435
|
+
MIT.
|