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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +435 -0
  5. data/app/controllers/dispatch_policy/application_controller.rb +9 -0
  6. data/app/controllers/dispatch_policy/policies_controller.rb +269 -0
  7. data/app/models/dispatch_policy/adaptive_concurrency_stats.rb +89 -0
  8. data/app/models/dispatch_policy/application_record.rb +7 -0
  9. data/app/models/dispatch_policy/partition_inflight_count.rb +42 -0
  10. data/app/models/dispatch_policy/partition_observation.rb +49 -0
  11. data/app/models/dispatch_policy/staged_job.rb +105 -0
  12. data/app/models/dispatch_policy/throttle_bucket.rb +41 -0
  13. data/app/views/dispatch_policy/policies/index.html.erb +52 -0
  14. data/app/views/dispatch_policy/policies/show.html.erb +241 -0
  15. data/app/views/layouts/dispatch_policy/application.html.erb +266 -0
  16. data/config/routes.rb +6 -0
  17. data/db/migrate/20260424000001_create_dispatch_policy_tables.rb +80 -0
  18. data/db/migrate/20260424000002_create_adaptive_concurrency_stats.rb +22 -0
  19. data/db/migrate/20260424000003_create_adaptive_concurrency_samples.rb +25 -0
  20. data/db/migrate/20260424000004_rename_samples_to_partition_observations.rb +32 -0
  21. data/lib/dispatch_policy/active_job_perform_all_later_patch.rb +32 -0
  22. data/lib/dispatch_policy/dispatch_context.rb +53 -0
  23. data/lib/dispatch_policy/dispatchable.rb +120 -0
  24. data/lib/dispatch_policy/engine.rb +36 -0
  25. data/lib/dispatch_policy/gate.rb +49 -0
  26. data/lib/dispatch_policy/gates/adaptive_concurrency.rb +123 -0
  27. data/lib/dispatch_policy/gates/concurrency.rb +43 -0
  28. data/lib/dispatch_policy/gates/fair_interleave.rb +32 -0
  29. data/lib/dispatch_policy/gates/global_cap.rb +26 -0
  30. data/lib/dispatch_policy/gates/throttle.rb +52 -0
  31. data/lib/dispatch_policy/install_generator.rb +23 -0
  32. data/lib/dispatch_policy/policy.rb +73 -0
  33. data/lib/dispatch_policy/tick.rb +214 -0
  34. data/lib/dispatch_policy/tick_loop.rb +45 -0
  35. data/lib/dispatch_policy/version.rb +5 -0
  36. data/lib/dispatch_policy.rb +64 -0
  37. 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.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DispatchPolicy
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
7
+ layout "dispatch_policy/application"
8
+ end
9
+ end