good_job 4.19.3 → 4.21.1
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 +87 -0
- data/README.md +64 -7
- data/app/charts/good_job/latency_metric/base.rb +21 -0
- data/app/charts/good_job/latency_metric/execution.rb +26 -0
- data/app/charts/good_job/latency_metric/queue.rb +22 -0
- data/app/charts/good_job/latency_metric/total.rb +22 -0
- data/app/charts/good_job/latency_metric.rb +42 -0
- data/app/charts/good_job/performance_index_chart.rb +10 -8
- data/app/charts/good_job/performance_show_chart.rb +12 -4
- data/app/controllers/good_job/performance_controller.rb +12 -18
- data/app/filters/good_job/base_filter.rb +12 -10
- data/app/filters/good_job/batches_filter.rb +12 -2
- data/app/frontend/good_job/modules/async_values_controller.js +7 -0
- data/app/frontend/good_job/modules/live_poll_controller.js +16 -1
- data/app/frontend/good_job/style.css +5 -0
- data/app/helpers/good_job/application_helper.rb +18 -0
- data/app/models/concerns/good_job/advisory_lockable.rb +13 -0
- data/app/models/concerns/good_job/filterable.rb +19 -6
- data/app/models/good_job/batch.rb +2 -4
- data/app/models/good_job/concurrency_claim.rb +162 -0
- data/app/models/good_job/cron_entry.rb +32 -13
- data/app/models/good_job/job.rb +47 -7
- data/app/models/good_job/process.rb +11 -8
- data/app/views/good_job/batches/_jobs.erb +1 -1
- data/app/views/good_job/batches/_table.erb +1 -1
- data/app/views/good_job/batches/show.html.erb +1 -1
- data/app/views/good_job/cron_entries/index.html.erb +1 -1
- data/app/views/good_job/jobs/_executions.erb +1 -1
- data/app/views/good_job/jobs/_table.erb +1 -1
- data/app/views/good_job/jobs/show.html.erb +2 -2
- data/app/views/good_job/performance/_latency.erb +6 -0
- data/app/views/good_job/performance/_metric.erb +8 -0
- data/app/views/good_job/performance/_range.erb +2 -2
- data/app/views/good_job/performance/index.html.erb +39 -49
- data/app/views/good_job/performance/show.html.erb +3 -1
- data/app/views/good_job/shared/_chart_container.erb +3 -1
- data/config/brakeman.ignore +38 -32
- data/config/locales/az.yml +20 -4
- data/config/locales/de.yml +20 -4
- data/config/locales/en.yml +20 -4
- data/config/locales/es.yml +20 -4
- data/config/locales/fr.yml +20 -4
- data/config/locales/it.yml +20 -4
- data/config/locales/ja.yml +20 -4
- data/config/locales/ko.yml +20 -4
- data/config/locales/nl.yml +20 -4
- data/config/locales/pt-BR.yml +20 -4
- data/config/locales/ru.yml +36 -20
- data/config/locales/tr.yml +20 -4
- data/config/locales/uk.yml +34 -18
- data/config/locales/zh-CN.yml +20 -4
- data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +34 -6
- data/lib/generators/good_job/templates/update/migrations/01_create_good_jobs.rb.erb +11 -6
- data/lib/generators/good_job/templates/update/migrations/16_add_index_good_jobs_dequeue_by_queue.rb.erb +21 -0
- data/lib/generators/good_job/templates/update/migrations/17_add_index_good_job_executions_on_scheduled_at.rb.erb +12 -0
- data/lib/generators/good_job/templates/update/migrations/18_add_batch_query_indexes.rb.erb +28 -0
- data/lib/generators/good_job/templates/update/migrations/19_remove_redundant_good_jobs_indexes.rb.erb +55 -0
- data/lib/generators/good_job/templates/update/migrations/20_reorder_good_jobs_cron_index.rb.erb +51 -0
- data/lib/generators/good_job/templates/update/migrations/21_create_good_job_concurrency_claims.rb.erb +27 -0
- data/lib/good_job/active_job_extensions/concurrency.rb +128 -42
- data/lib/good_job/active_job_extensions/labels.rb +4 -1
- data/lib/good_job/bulk.rb +1 -2
- data/lib/good_job/capsule.rb +1 -1
- data/lib/good_job/capsule_tracker.rb +84 -55
- data/lib/good_job/cli.rb +32 -1
- data/lib/good_job/configuration.rb +92 -3
- data/lib/good_job/cron_manager.rb +31 -7
- data/lib/good_job/lifecycle_hooks.rb +83 -0
- data/lib/good_job/log_subscriber.rb +78 -0
- data/lib/good_job/multi_scheduler.rb +2 -1
- data/lib/good_job/notifier/process_heartbeat.rb +30 -6
- data/lib/good_job/notifier.rb +5 -3
- data/lib/good_job/probe_server/cluster_healthcheck_middleware.rb +32 -0
- data/lib/good_job/probe_server/simple_handler.rb +14 -9
- data/lib/good_job/probe_server/webrick_handler.rb +12 -0
- data/lib/good_job/probe_server.rb +18 -0
- data/lib/good_job/shared_executor.rb +3 -1
- data/lib/good_job/subprocess.rb +135 -0
- data/lib/good_job/supervisor.rb +536 -0
- data/lib/good_job/systemd_service.rb +12 -1
- data/lib/good_job/version.rb +1 -1
- data/lib/good_job.rb +17 -3
- metadata +33 -2
- data/lib/good_job/overridable_connection.rb +0 -38
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 510354d3834b40133fff5b01cda0ac035f58b327317098467a305ac2ca5372d4
|
|
4
|
+
data.tar.gz: 0bee2064ac6fc2f6d89c69e22ed3639d07341120a4dfecb0d77e025e94455148
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae50900ba6a6db18dc13112a3999a12fc215e1f0f856e761b253fbfa02689ded6a01e3c2bb7aaf776465e822fb7b0ca1d2075dff9c784fc14e62a4ebbec82dcb
|
|
7
|
+
data.tar.gz: 5b939f61fa88f4dbdfed4ef48bc7ea0d0702e09b01c8e7ca7008e2e9cf4c112568e0a299197eb7985da9fb05e89a81d5d332a4bedbc98aac7dad6268d9f91579
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [v4.21.1](https://github.com/bensheldon/good_job/tree/v4.21.1) (2026-10-07)
|
|
4
|
+
|
|
5
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.21.0...v4.21.1)
|
|
6
|
+
|
|
7
|
+
**Fixed bugs:**
|
|
8
|
+
|
|
9
|
+
- Fix crash on natural language cron schedules \(v4.21.0 regression\) [\#1853](https://github.com/bensheldon/good_job/pull/1853) ([bensheldon](https://github.com/bensheldon))
|
|
10
|
+
|
|
11
|
+
**Closed issues:**
|
|
12
|
+
|
|
13
|
+
- v4.21.0 regression: undefined method 'next\_time' for an instance of EtOrbi::EoTime [\#1852](https://github.com/bensheldon/good_job/issues/1852)
|
|
14
|
+
|
|
15
|
+
## [v4.21.0](https://github.com/bensheldon/good_job/tree/v4.21.0) (2026-10-06)
|
|
16
|
+
|
|
17
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.20.0...v4.21.0)
|
|
18
|
+
|
|
19
|
+
**Implemented enhancements:**
|
|
20
|
+
|
|
21
|
+
- Enqueue jobs with concurrency rules individually in bulk/batch, and support Lambda class-level labels [\#1851](https://github.com/bensheldon/good_job/pull/1851) ([bensheldon](https://github.com/bensheldon))
|
|
22
|
+
|
|
23
|
+
**Closed issues:**
|
|
24
|
+
|
|
25
|
+
- perform\_limit: 1 permits two same-label jobs to execute concurrently [\#1843](https://github.com/bensheldon/good_job/issues/1843)
|
|
26
|
+
|
|
27
|
+
**Merged pull requests:**
|
|
28
|
+
|
|
29
|
+
- Fix: Run the enqueue concurrency check always after the job's other enqueue callbacks [\#1850](https://github.com/bensheldon/good_job/pull/1850) ([bensheldon](https://github.com/bensheldon))
|
|
30
|
+
- Add updated\_at to good\_job\_concurrency\_claims [\#1849](https://github.com/bensheldon/good_job/pull/1849) ([bensheldon](https://github.com/bensheldon))
|
|
31
|
+
- Fix Russian/Ukrainian "one" plurals showing "1" for 21, 31, … and Russian apply\_to\_all text [\#1848](https://github.com/bensheldon/good_job/pull/1848) ([owgreen-dev](https://github.com/owgreen-dev))
|
|
32
|
+
- Deprecate combining label: and key: in concurrency rules [\#1847](https://github.com/bensheldon/good_job/pull/1847) ([bensheldon](https://github.com/bensheldon))
|
|
33
|
+
- Run specs in parallel with parallel\_tests [\#1845](https://github.com/bensheldon/good_job/pull/1845) ([bensheldon](https://github.com/bensheldon))
|
|
34
|
+
- Fix perform\_limit race and promote waiting jobs when a concurrency claim is released [\#1844](https://github.com/bensheldon/good_job/pull/1844) ([bensheldon](https://github.com/bensheldon))
|
|
35
|
+
- Fix Ukrainian x\_minutes "few" form showing "1" for every count [\#1842](https://github.com/bensheldon/good_job/pull/1842) ([owgreen-dev](https://github.com/owgreen-dev))
|
|
36
|
+
- Fix flaky CronManager graceful restart spec at minute boundary [\#1841](https://github.com/bensheldon/good_job/pull/1841) ([bensheldon](https://github.com/bensheldon))
|
|
37
|
+
- Validate cron entry keys and schedules instead of raising [\#1832](https://github.com/bensheldon/good_job/pull/1832) ([shiaho777](https://github.com/shiaho777))
|
|
38
|
+
- Fix concurrency label matching and document dynamic labels [\#1827](https://github.com/bensheldon/good_job/pull/1827) ([shiaho777](https://github.com/shiaho777))
|
|
39
|
+
- Add queue and total latency to the Performance dashboard [\#1812](https://github.com/bensheldon/good_job/pull/1812) ([dennispaagman](https://github.com/dennispaagman))
|
|
40
|
+
- Remove connection overrides from process heartbeats [\#1763](https://github.com/bensheldon/good_job/pull/1763) ([bensheldon](https://github.com/bensheldon))
|
|
41
|
+
|
|
42
|
+
## [v4.20.0](https://github.com/bensheldon/good_job/tree/v4.20.0) (2026-10-03)
|
|
43
|
+
|
|
44
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.4...v4.20.0)
|
|
45
|
+
|
|
46
|
+
**Implemented enhancements:**
|
|
47
|
+
|
|
48
|
+
- Match identifier-like strings in dashboard search [\#1831](https://github.com/bensheldon/good_job/pull/1831) ([shiaho777](https://github.com/shiaho777))
|
|
49
|
+
- Add a queue-scoped index for the dequeue candidate query [\#1810](https://github.com/bensheldon/good_job/pull/1810) ([a-abdellatif98](https://github.com/a-abdellatif98))
|
|
50
|
+
- Add cluster mode [\#1803](https://github.com/bensheldon/good_job/pull/1803) ([bensheldon](https://github.com/bensheldon))
|
|
51
|
+
|
|
52
|
+
**Fixed bugs:**
|
|
53
|
+
|
|
54
|
+
- \[dashboard\] Mismatch between page header vs graph headers [\#1601](https://github.com/bensheldon/good_job/issues/1601)
|
|
55
|
+
- Add dashboard and batch indexes, remove redundant indexes, and reorder the cron index [\#1840](https://github.com/bensheldon/good_job/pull/1840) ([bensheldon](https://github.com/bensheldon))
|
|
56
|
+
- Detect Puma worker boot hooks and Puma 8 requests in `in_webserver?` [\#1836](https://github.com/bensheldon/good_job/pull/1836) ([amanfredi](https://github.com/amanfredi))
|
|
57
|
+
- Keep both SharedExecutor threads available on JRuby [\#1835](https://github.com/bensheldon/good_job/pull/1835) ([amanfredi](https://github.com/amanfredi))
|
|
58
|
+
- Pass `cron_graceful_restart_period` to the CronManager so it takes effect [\#1833](https://github.com/bensheldon/good_job/pull/1833) ([amanfredi](https://github.com/amanfredi))
|
|
59
|
+
- Wrap Notifier's NOTIFY recipient dispatch in the Rails executor [\#1830](https://github.com/bensheldon/good_job/pull/1830) ([shiaho777](https://github.com/shiaho777))
|
|
60
|
+
- Preserve active backtrace tabs across live poll refreshes [\#1824](https://github.com/bensheldon/good_job/pull/1824) ([shiaho777](https://github.com/shiaho777))
|
|
61
|
+
- Refetch async navbar values after a live poll refresh [\#1823](https://github.com/bensheldon/good_job/pull/1823) ([shiaho777](https://github.com/shiaho777))
|
|
62
|
+
- Truncate long strings when displaying job params on the dashboard [\#1822](https://github.com/bensheldon/good_job/pull/1822) ([shiaho777](https://github.com/shiaho777))
|
|
63
|
+
- Bound all search\_text documents under the tsvector size limit [\#1820](https://github.com/bensheldon/good_job/pull/1820) ([shiaho777](https://github.com/shiaho777))
|
|
64
|
+
- Finish a job's unfinished executions when it is discarded [\#1819](https://github.com/bensheldon/good_job/pull/1819) ([shiaho777](https://github.com/shiaho777))
|
|
65
|
+
|
|
66
|
+
**Closed issues:**
|
|
67
|
+
|
|
68
|
+
- Dequeue candidate indexes don't cover queue-filtered workers — one queue's backlog degrades every other queue's claim query [\#1790](https://github.com/bensheldon/good_job/issues/1790)
|
|
69
|
+
- PG: ProgramLimitExceeded during search when a job has a huge error message [\#1785](https://github.com/bensheldon/good_job/issues/1785)
|
|
70
|
+
- Very large `serialized_params` caused dashboard timeout on Heroku [\#1629](https://github.com/bensheldon/good_job/issues/1629)
|
|
71
|
+
- \[dashboard\] Viewing a job & live poll resets the Full Trace vs Application Trace [\#1606](https://github.com/bensheldon/good_job/issues/1606)
|
|
72
|
+
- \[GoodJob\] Notifier errored: NameError: uninitialized constant GoodJob::Job in develpment [\#1571](https://github.com/bensheldon/good_job/issues/1571)
|
|
73
|
+
- Bug: calling GoodJob.restart in Puma on\_worker\_boot still starts GoodJob regardless of it being in async mode or not [\#1531](https://github.com/bensheldon/good_job/issues/1531)
|
|
74
|
+
- Discarding a job via the dashboard should also update the discrete\_execution [\#1093](https://github.com/bensheldon/good_job/issues/1093)
|
|
75
|
+
- A copy of GoodJob::Execution has been removed from the module tree but is still active! [\#954](https://github.com/bensheldon/good_job/issues/954)
|
|
76
|
+
|
|
77
|
+
**Merged pull requests:**
|
|
78
|
+
|
|
79
|
+
- Wait for Turbo navigation before reading the URL in performance specs [\#1838](https://github.com/bensheldon/good_job/pull/1838) ([amanfredi](https://github.com/amanfredi))
|
|
80
|
+
- Bump github/codeql-action from 4.37.9 to 4.38.2 [\#1837](https://github.com/bensheldon/good_job/pull/1837) ([dependabot[bot]](https://github.com/apps/dependabot))
|
|
81
|
+
|
|
82
|
+
## [v4.19.4](https://github.com/bensheldon/good_job/tree/v4.19.4) (2026-10-02)
|
|
83
|
+
|
|
84
|
+
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.3...v4.19.4)
|
|
85
|
+
|
|
86
|
+
**Merged pull requests:**
|
|
87
|
+
|
|
88
|
+
- fix: Render batch details with JSON 3 [\#1839](https://github.com/bensheldon/good_job/pull/1839) ([luizkowalski](https://github.com/luizkowalski))
|
|
89
|
+
|
|
3
90
|
## [v4.19.3](https://github.com/bensheldon/good_job/tree/v4.19.3) (2026-09-21)
|
|
4
91
|
|
|
5
92
|
[Full Changelog](https://github.com/bensheldon/good_job/compare/v4.19.2...v4.19.3)
|
data/README.md
CHANGED
|
@@ -47,6 +47,7 @@ For more of the story of GoodJob, read the [introductory blog post](https://isla
|
|
|
47
47
|
- [Extending dashboard views](#extending-dashboard-views)
|
|
48
48
|
- [Job priority](#job-priority)
|
|
49
49
|
- [Concurrency controls](#concurrency-controls)
|
|
50
|
+
- [Dynamic labels](#dynamic-labels)
|
|
50
51
|
- [How concurrency controls work](#how-concurrency-controls-work)
|
|
51
52
|
- [Cron-style repeating/recurring jobs](#cron-style-repeatingrecurring-jobs)
|
|
52
53
|
- [Bulk enqueue](#bulk-enqueue)
|
|
@@ -308,7 +309,7 @@ Available configuration options are:
|
|
|
308
309
|
- `max_cache` (integer) sets the maximum number of scheduled jobs that will be stored in memory to reduce execution latency when also polling for scheduled jobs. Caching 10,000 scheduled jobs uses approximately 20MB of memory. You can also set this with the environment variable `GOOD_JOB_MAX_CACHE`.
|
|
309
310
|
- `shutdown_timeout` (integer) number of seconds to wait for jobs to finish when shutting down before stopping the thread. Defaults to forever: `-1`. You can also set this with the environment variable `GOOD_JOB_SHUTDOWN_TIMEOUT`.
|
|
310
311
|
- `enable_cron` (boolean) whether to run cron process. Defaults to `false`. You can also set this with the environment variable `GOOD_JOB_ENABLE_CRON`.
|
|
311
|
-
- `cron_graceful_restart_period` (integer) when
|
|
312
|
+
- `cron_graceful_restart_period` (integer seconds or `ActiveSupport::Duration`) when cron starts, attempt to re-enqueue jobs that would have been enqueued by cron within this time period (e.g. `1.minute`). This should match the expected downtime during deploys. Like enabling cron on multiple processes, this relies on cron-created job records being preserved to avoid re-enqueuing jobs that already ran, so it is ignored when `preserve_job_records` is `false` or `:on_unhandled_error`. It does not apply to a `cron` proc that returns a time, which already enqueues the runs it missed since its last job. Defaults to `nil` (disabled). You can also set this with the environment variable `GOOD_JOB_CRON_GRACEFUL_RESTART_PERIOD`.
|
|
312
313
|
- `enable_listen_notify` (boolean) whether to enqueue and read jobs with Postgres LISTEN/NOTIFY. Defaults to `true`. You can also set this with the environment variable `GOOD_JOB_ENABLE_LISTEN_NOTIFY`.
|
|
313
314
|
- `cron` (hash) cron configuration. Defaults to `{}`. You can also set this as a JSON string with the environment variable `GOOD_JOB_CRON`
|
|
314
315
|
- `cleanup_discarded_jobs` (boolean) whether to destroy discarded jobs when cleaning up preserved jobs using the `$ good_job cleanup_preserved_jobs` CLI command or calling `GoodJob.cleanup_preserved_jobs`. Defaults to `true`. Can also be set with the environment variable `GOOD_JOB_CLEANUP_DISCARDED_JOBS`.
|
|
@@ -561,9 +562,10 @@ class MyJob < ApplicationJob
|
|
|
561
562
|
# exceeded rule short-circuits the rest.
|
|
562
563
|
good_job_concurrency_rule(
|
|
563
564
|
# A label that scopes this rule. Can be a static String or a Lambda/Proc
|
|
564
|
-
# invoked in the context of the job instance
|
|
565
|
-
# that were enqueued with this label in
|
|
566
|
-
|
|
565
|
+
# invoked in the context of the job instance (see "Dynamic labels" below).
|
|
566
|
+
# The rule only applies to jobs that were enqueued with this label in
|
|
567
|
+
# `good_job_labels`.
|
|
568
|
+
label: "email",
|
|
567
569
|
|
|
568
570
|
# Maximum number of unfinished jobs with this label to allow.
|
|
569
571
|
# Can be an Integer or Lambda/Proc invoked in the context of the job.
|
|
@@ -595,7 +597,7 @@ class MyJob < ApplicationJob
|
|
|
595
597
|
good_job_concurrency_rule(...)
|
|
596
598
|
good_job_concurrency_rule(...)
|
|
597
599
|
|
|
598
|
-
def perform
|
|
600
|
+
def perform
|
|
599
601
|
# do work
|
|
600
602
|
end
|
|
601
603
|
end
|
|
@@ -604,16 +606,71 @@ end
|
|
|
604
606
|
Jobs must be enqueued with the matching label for rules to take effect:
|
|
605
607
|
|
|
606
608
|
```ruby
|
|
607
|
-
MyJob.set(good_job_labels: [
|
|
609
|
+
MyJob.set(good_job_labels: ["email"]).perform_later
|
|
608
610
|
```
|
|
609
611
|
|
|
612
|
+
Supplying both `label:` and `key:` to `good_job_concurrency_rule` is deprecated. For labelled rules, `key:` is ignored: jobs sharing the label use the same advisory lock for concurrency checks. Remove `key:` from these rules. The legacy `good_job_control_concurrency_with(key: ...)` interface remains supported.
|
|
613
|
+
|
|
614
|
+
#### Dynamic labels
|
|
615
|
+
|
|
616
|
+
A rule's `label:` can also be a Lambda/Proc that is invoked in the context of the job instance, for example to derive the label from job arguments. The lambda resolves the label to check against; the job's `good_job_labels` must still contain it for the rule to apply. Rule labels and job labels are converted to strings and stripped of surrounding whitespace when matched, consistent with how labels are stored.
|
|
617
|
+
|
|
618
|
+
To apply a dynamic label to every job of a class, set a Lambda/Proc in the class-level `good_job_labels`. It is invoked in the context of the job when the job is initialized, so the label is present for checks at both enqueue and perform time:
|
|
619
|
+
|
|
620
|
+
```ruby
|
|
621
|
+
class MyJob < ApplicationJob
|
|
622
|
+
include GoodJob::ActiveJobExtensions::Concurrency
|
|
623
|
+
|
|
624
|
+
self.good_job_labels = [-> { "user-#{arguments.first[:user_id]}" }]
|
|
625
|
+
|
|
626
|
+
good_job_concurrency_rule(
|
|
627
|
+
label: -> { "user-#{arguments.first[:user_id]}" },
|
|
628
|
+
total_limit: 1
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
def perform(user_id:)
|
|
632
|
+
# do work
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
Labels can also be applied in a `before_enqueue` callback. They are stored on the job record and checked by the rules:
|
|
638
|
+
|
|
639
|
+
```ruby
|
|
640
|
+
class MyJob < ApplicationJob
|
|
641
|
+
include GoodJob::ActiveJobExtensions::Concurrency
|
|
642
|
+
|
|
643
|
+
before_enqueue do |job|
|
|
644
|
+
job.good_job_labels = [job.arguments.first[:user_id].to_s]
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
good_job_concurrency_rule(
|
|
648
|
+
label: -> { arguments.first[:user_id].to_s },
|
|
649
|
+
perform_limit: 1
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
def perform(user_id:)
|
|
653
|
+
# do work
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Rules are checked when a job is enqueued and again when it is performed. The enqueue-time check runs after the job's other `before_enqueue` and `around_enqueue` callbacks, regardless of where they are defined, so labels assigned in those callbacks are present for both checks. Labels can also be passed when enqueuing:
|
|
659
|
+
|
|
660
|
+
```ruby
|
|
661
|
+
MyJob.set(good_job_labels: [user_id.to_s]).perform_later(user_id: user_id)
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Rules apply across job classes to jobs carrying the resolved label. `total_limit` counts unfinished jobs, `enqueue_limit` excludes claimed/performing jobs, and `perform_limit` counts running jobs. Throttles count enqueued jobs or executions within their time window, including finished ones.
|
|
665
|
+
|
|
610
666
|
#### How concurrency controls work
|
|
611
667
|
|
|
612
668
|
GoodJob's concurrency control strategy for `perform_limit` is "optimistic retry with an incremental backoff". The [code is readable](https://github.com/bensheldon/good_job/blob/main/lib/good_job/active_job_extensions/concurrency.rb).
|
|
613
669
|
|
|
614
670
|
- "Optimistic" meaning that the implementation's performance trade-off assumes that collisions are atypical (e.g. two users enqueue the same job at the same time) rather than regular (e.g. the system enqueues thousands of colliding jobs at the same time). Depending on your concurrency requirements, you may also want to manage concurrency through the number of GoodJob threads and processes that are performing a given queue.
|
|
615
671
|
- "Retry with an incremental backoff" means that when `perform_limit` is exceeded, the job will raise a `GoodJob::ActiveJobExtensions::Concurrency::ConcurrencyExceededError` which is caught by a `retry_on` handler which re-schedules the job to execute in the near future with an incremental backoff.
|
|
616
|
-
-
|
|
672
|
+
- When the `good_job_concurrency_claims` table has been migrated, a job that exceeded `perform_limit` waits on the limit's key, and when a job holding that key finishes, the longest-waiting job is re-scheduled to run immediately rather than waiting for its backoff. The incremental backoff remains as a fallback.
|
|
673
|
+
- First-in-first-out job execution order is not strictly preserved: a waiting job that is re-scheduled can be overtaken by a newly enqueued job, and jobs retried with incremental back-off run in backoff order.
|
|
617
674
|
- For pessimistic usecases that collisions are expected, use number of threads/processes (e.g., `good_job --queues "serial:1;-serial:5"`) to control concurrency. It is also a good idea to use `perform_limit` as backstop.
|
|
618
675
|
|
|
619
676
|
#### Legacy: `good_job_control_concurrency_with`
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodJob
|
|
4
|
+
module LatencyMetric
|
|
5
|
+
# A measurement the Performance charts can plot. Subclasses supply a SQL
|
|
6
|
+
# expression over +good_job_executions+, the aggregate that summarizes it per
|
|
7
|
+
# time bucket, and their titles.
|
|
8
|
+
class Base
|
|
9
|
+
def expression = self.class::EXPRESSION
|
|
10
|
+
|
|
11
|
+
def to_arel = Arel.sql(expression)
|
|
12
|
+
|
|
13
|
+
# nil plots an empty bucket as a gap; a summed metric overrides this with a true zero.
|
|
14
|
+
def empty_value = nil
|
|
15
|
+
|
|
16
|
+
def to_params = { PARAMETER_KEY => key.to_s }
|
|
17
|
+
|
|
18
|
+
def span_gaps? = empty_value.nil?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodJob
|
|
4
|
+
module LatencyMetric
|
|
5
|
+
# How long jobs took to run. Summed across a bucket because total execution time
|
|
6
|
+
# is a measure of load.
|
|
7
|
+
class Execution < Base
|
|
8
|
+
EXPRESSION = "duration"
|
|
9
|
+
|
|
10
|
+
def key = :execution
|
|
11
|
+
|
|
12
|
+
def aggregate = "SUM"
|
|
13
|
+
|
|
14
|
+
def presence_sql = "duration IS NOT NULL"
|
|
15
|
+
|
|
16
|
+
def empty_value = 0
|
|
17
|
+
|
|
18
|
+
# The default metric stays out of navigation URLs.
|
|
19
|
+
def to_params = {}
|
|
20
|
+
|
|
21
|
+
def chart_title = I18n.t("good_job.performance.index.chart_title")
|
|
22
|
+
|
|
23
|
+
def histogram_title = I18n.t("good_job.performance.show.execution_chart_title")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodJob
|
|
4
|
+
module LatencyMetric
|
|
5
|
+
# How long jobs waited between becoming eligible and actually starting, the same
|
|
6
|
+
# quantity as GoodJob::Execution#queue_latency. Averaged across a bucket because a
|
|
7
|
+
# summed wait would grow with volume alone.
|
|
8
|
+
class Queue < Base
|
|
9
|
+
EXPRESSION = "(created_at - scheduled_at)"
|
|
10
|
+
|
|
11
|
+
def key = :queue
|
|
12
|
+
|
|
13
|
+
def aggregate = "AVG"
|
|
14
|
+
|
|
15
|
+
def presence_sql = "scheduled_at IS NOT NULL"
|
|
16
|
+
|
|
17
|
+
def chart_title = I18n.t("good_job.performance.index.chart_title_queue")
|
|
18
|
+
|
|
19
|
+
def histogram_title = I18n.t("good_job.performance.show.queue_chart_title")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodJob
|
|
4
|
+
module LatencyMetric
|
|
5
|
+
# How long jobs took end to end: the wait before starting plus the run itself.
|
|
6
|
+
# Averaged across a bucket for the same reason as Queue.
|
|
7
|
+
class Total < Base
|
|
8
|
+
# NULL while an execution is still running, so the aggregates skip it rather than count zero.
|
|
9
|
+
EXPRESSION = "(#{GoodJob::LatencyMetric::Queue::EXPRESSION} + #{GoodJob::LatencyMetric::Execution::EXPRESSION})".freeze
|
|
10
|
+
|
|
11
|
+
def key = :total
|
|
12
|
+
|
|
13
|
+
def aggregate = "AVG"
|
|
14
|
+
|
|
15
|
+
def presence_sql = "scheduled_at IS NOT NULL AND duration IS NOT NULL"
|
|
16
|
+
|
|
17
|
+
def chart_title = I18n.t("good_job.performance.index.chart_title_total")
|
|
18
|
+
|
|
19
|
+
def histogram_title = I18n.t("good_job.performance.show.total_chart_title")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodJob
|
|
4
|
+
# The measurements the Performance charts plot and the index tables aggregate.
|
|
5
|
+
module LatencyMetric
|
|
6
|
+
PARAMETER_KEY = :chart
|
|
7
|
+
|
|
8
|
+
def self.options
|
|
9
|
+
[
|
|
10
|
+
GoodJob::LatencyMetric::Execution.new,
|
|
11
|
+
GoodJob::LatencyMetric::Queue.new,
|
|
12
|
+
GoodJob::LatencyMetric::Total.new,
|
|
13
|
+
]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.default = options.first
|
|
17
|
+
|
|
18
|
+
def self.table_aggregates_sql
|
|
19
|
+
<<~SQL.squish
|
|
20
|
+
COUNT(DISTINCT active_job_id) AS jobs_count,
|
|
21
|
+
COUNT(*) AS executions_count,
|
|
22
|
+
AVG(#{GoodJob::LatencyMetric::Queue::EXPRESSION}) AS avg_queue,
|
|
23
|
+
MIN(#{GoodJob::LatencyMetric::Queue::EXPRESSION}) AS min_queue,
|
|
24
|
+
MAX(#{GoodJob::LatencyMetric::Queue::EXPRESSION}) AS max_queue,
|
|
25
|
+
AVG(#{GoodJob::LatencyMetric::Execution::EXPRESSION}) AS avg_execution,
|
|
26
|
+
MIN(#{GoodJob::LatencyMetric::Execution::EXPRESSION}) AS min_execution,
|
|
27
|
+
MAX(#{GoodJob::LatencyMetric::Execution::EXPRESSION}) AS max_execution,
|
|
28
|
+
AVG(#{GoodJob::LatencyMetric::Total::EXPRESSION}) AS avg_total,
|
|
29
|
+
MIN(#{GoodJob::LatencyMetric::Total::EXPRESSION}) AS min_total,
|
|
30
|
+
MAX(#{GoodJob::LatencyMetric::Total::EXPRESSION}) AS max_total
|
|
31
|
+
SQL
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Compare as strings: the value is whatever the query string held, so array- and
|
|
35
|
+
# hash-valued input has to fall through to the default rather than be coerced.
|
|
36
|
+
def self.from_params(params)
|
|
37
|
+
key = params[PARAMETER_KEY]
|
|
38
|
+
|
|
39
|
+
options.find { |metric| metric.key.to_s == key } || default
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module GoodJob
|
|
4
4
|
class PerformanceIndexChart < BaseChart
|
|
5
|
-
def initialize(range = GoodJob::PerformanceRange.new)
|
|
5
|
+
def initialize(range = GoodJob::PerformanceRange.new, metric = GoodJob::LatencyMetric.default)
|
|
6
6
|
super()
|
|
7
7
|
@range = range
|
|
8
|
+
@metric = metric
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def data
|
|
@@ -14,7 +15,7 @@ module GoodJob
|
|
|
14
15
|
start_expression: "range_parameters.series_start_time",
|
|
15
16
|
interval_expression: "range_parameters.interval_seconds"
|
|
16
17
|
)
|
|
17
|
-
inner_sql = @range.apply(GoodJob::Execution).select(:job_class, :scheduled_at,
|
|
18
|
+
inner_sql = @range.apply(GoodJob::Execution).select(:job_class, :scheduled_at, @metric.to_arel.as("metric_value")).to_sql
|
|
18
19
|
|
|
19
20
|
# Keep each bind placeholder single-use because JDBC binds every converted occurrence.
|
|
20
21
|
sum_query = <<~SQL.squish
|
|
@@ -34,7 +35,7 @@ module GoodJob
|
|
|
34
35
|
SELECT
|
|
35
36
|
#{bucket_sql} AS scheduled_at,
|
|
36
37
|
job_class,
|
|
37
|
-
|
|
38
|
+
#{@metric.aggregate}(metric_value) AS value
|
|
38
39
|
FROM (#{inner_sql}) executions
|
|
39
40
|
CROSS JOIN range_parameters
|
|
40
41
|
GROUP BY #{bucket_sql}, job_class
|
|
@@ -47,16 +48,16 @@ module GoodJob
|
|
|
47
48
|
|
|
48
49
|
executions_data = GoodJob::Job.connection_pool.with_connection { |conn| conn.exec_query(GoodJob::Job.pg_or_jdbc_query(sum_query), "GoodJob Performance Chart", binds) }
|
|
49
50
|
|
|
50
|
-
job_names = executions_data.reject { |d| d['
|
|
51
|
+
job_names = executions_data.reject { |d| d['value'].nil? }.map { |d| d['job_class'] || BaseFilter::EMPTY }.uniq
|
|
51
52
|
timestamp_values = []
|
|
52
53
|
timestamps = []
|
|
53
54
|
jobs_data = executions_data.to_a.group_by { |d| d['timestamp'] }.each_with_object({}) do |(timestamp, values), hash|
|
|
54
55
|
timestamp_values << timestamp
|
|
55
56
|
timestamps << @range.canonical_timestamp(timestamp)
|
|
56
57
|
job_names.each do |job_class|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
(hash[job_class] ||= []) <<
|
|
58
|
+
value = values.find { |d| d['job_class'] == job_class }&.[]('value')
|
|
59
|
+
seconds = value ? ActiveSupport::Duration.parse(value).to_f : @metric.empty_value
|
|
60
|
+
(hash[job_class] ||= []) << seconds
|
|
60
61
|
end
|
|
61
62
|
end
|
|
62
63
|
labels = @range.chart_timestamp_labels(timestamp_values)
|
|
@@ -72,6 +73,7 @@ module GoodJob
|
|
|
72
73
|
data: data,
|
|
73
74
|
backgroundColor: string_to_hsl(label),
|
|
74
75
|
borderColor: string_to_hsl(label),
|
|
76
|
+
spanGaps: @metric.span_gaps?,
|
|
75
77
|
}
|
|
76
78
|
end,
|
|
77
79
|
},
|
|
@@ -79,7 +81,7 @@ module GoodJob
|
|
|
79
81
|
plugins: {
|
|
80
82
|
title: {
|
|
81
83
|
display: true,
|
|
82
|
-
text:
|
|
84
|
+
text: @metric.chart_title,
|
|
83
85
|
},
|
|
84
86
|
legend: {
|
|
85
87
|
vertical: true,
|
|
@@ -12,26 +12,28 @@ module GoodJob
|
|
|
12
12
|
10**8 # About 3 years
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
|
-
def initialize(job_class, range = GoodJob::PerformanceRange.new)
|
|
15
|
+
def initialize(job_class, range = GoodJob::PerformanceRange.new, metric = GoodJob::LatencyMetric.default)
|
|
16
16
|
super()
|
|
17
17
|
@job_class = job_class
|
|
18
18
|
@range = range
|
|
19
|
+
@metric = metric
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def data
|
|
22
23
|
table_name = GoodJob::Execution.table_name
|
|
23
24
|
|
|
24
25
|
interval_entries = BUCKET_INTERVALS.map { "interval '#{_1}'" }.join(",")
|
|
26
|
+
bucket_expression = "WIDTH_BUCKET(#{@metric.expression}, ARRAY[#{interval_entries}])"
|
|
25
27
|
sum_query = <<~SQL.squish
|
|
26
28
|
SELECT
|
|
27
|
-
|
|
28
|
-
COUNT(
|
|
29
|
+
#{bucket_expression} as bucket_index,
|
|
30
|
+
COUNT(#{bucket_expression}) AS count
|
|
29
31
|
FROM #{table_name} sources
|
|
30
32
|
WHERE
|
|
31
33
|
scheduled_at >= $1::timestamp
|
|
32
34
|
AND scheduled_at < $2::timestamp
|
|
33
35
|
AND job_class = $3
|
|
34
|
-
AND
|
|
36
|
+
AND #{@metric.presence_sql}
|
|
35
37
|
GROUP BY bucket_index
|
|
36
38
|
SQL
|
|
37
39
|
|
|
@@ -60,6 +62,12 @@ module GoodJob
|
|
|
60
62
|
}],
|
|
61
63
|
},
|
|
62
64
|
options: {
|
|
65
|
+
plugins: {
|
|
66
|
+
title: {
|
|
67
|
+
display: true,
|
|
68
|
+
text: @metric.histogram_title,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
63
71
|
scales: {
|
|
64
72
|
y: {
|
|
65
73
|
beginAtZero: true,
|
|
@@ -6,30 +6,20 @@ module GoodJob
|
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
8
|
executions = @performance_range.apply(GoodJob::Execution)
|
|
9
|
+
aggregates = GoodJob::LatencyMetric.table_aggregates_sql
|
|
9
10
|
|
|
10
|
-
@performances = executions.group(:job_class).select("
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
MIN(duration) AS min_duration,
|
|
15
|
-
MAX(duration) AS max_duration
|
|
16
|
-
").order(:job_class)
|
|
17
|
-
|
|
18
|
-
@queue_performances = executions.group(:queue_name).select("
|
|
19
|
-
queue_name,
|
|
20
|
-
COUNT(*) AS executions_count,
|
|
21
|
-
AVG(duration) AS avg_duration,
|
|
22
|
-
MIN(duration) AS min_duration,
|
|
23
|
-
MAX(duration) AS max_duration
|
|
24
|
-
").order(:queue_name)
|
|
25
|
-
|
|
26
|
-
@chart_data = GoodJob::PerformanceIndexChart.new(@performance_range).data
|
|
11
|
+
@performances = executions.group(:job_class).select("job_class, #{aggregates}").order(:job_class)
|
|
12
|
+
@queue_performances = executions.group(:queue_name).select("queue_name, #{aggregates}").order(:queue_name)
|
|
13
|
+
|
|
14
|
+
@chart_data = GoodJob::PerformanceIndexChart.new(@performance_range, @chart_metric).data
|
|
27
15
|
end
|
|
28
16
|
|
|
29
17
|
def show
|
|
30
18
|
representative_job = GoodJob::Job.find_by!(job_class: request.path_parameters.fetch(:id))
|
|
31
19
|
@job_class = representative_job.job_class
|
|
32
|
-
@
|
|
20
|
+
@charts = GoodJob::LatencyMetric.options.to_h do |metric|
|
|
21
|
+
[metric.key, GoodJob::PerformanceShowChart.new(@job_class, @performance_range, metric).data]
|
|
22
|
+
end
|
|
33
23
|
end
|
|
34
24
|
|
|
35
25
|
private
|
|
@@ -38,10 +28,14 @@ module GoodJob
|
|
|
38
28
|
locale = request.query_parameters["locale"]
|
|
39
29
|
@performance_range_context = locale.is_a?(String) ? { "locale" => locale } : {}
|
|
40
30
|
@performance_range = GoodJob::PerformanceRange.new(params, query_string: request.query_string)
|
|
31
|
+
# Only the index chart is metric-switchable; show renders every metric.
|
|
32
|
+
@chart_metric = GoodJob::LatencyMetric.from_params(params)
|
|
33
|
+
@chart_metric_params = action_name == "index" ? @chart_metric.to_params : {}
|
|
41
34
|
return if @performance_range.canonical_parameters?(request.query_parameters)
|
|
42
35
|
|
|
43
36
|
canonical_query = @performance_range.to_params.symbolize_keys
|
|
44
37
|
canonical_query[:locale] = @performance_range_context["locale"]
|
|
38
|
+
canonical_query.merge!(@chart_metric_params.symbolize_keys)
|
|
45
39
|
canonical_path = if action_name == "show"
|
|
46
40
|
performance_path(request.path_parameters.fetch(:id), canonical_query)
|
|
47
41
|
else
|
|
@@ -13,15 +13,17 @@ module GoodJob
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def records
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
@_records ||= begin
|
|
17
|
+
after_id = params[:after_id]
|
|
18
|
+
after_at = params[:after_at].present? ? Time.zone.parse(params[:after_at]) : nil
|
|
19
|
+
limit = params.fetch(:limit, DEFAULT_LIMIT)
|
|
20
|
+
|
|
21
|
+
query_for_records.display_all(
|
|
22
|
+
ordered_by: ordered_by,
|
|
23
|
+
after_at: after_at,
|
|
24
|
+
after_id: after_id
|
|
25
|
+
).limit(limit)
|
|
26
|
+
end
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def last
|
|
@@ -76,7 +78,7 @@ module GoodJob
|
|
|
76
78
|
|
|
77
79
|
def next_page_params
|
|
78
80
|
order_column = ordered_by.first
|
|
79
|
-
last_record =
|
|
81
|
+
last_record = last
|
|
80
82
|
|
|
81
83
|
{
|
|
82
84
|
after_at: last_record&.send(order_column)&.iso8601(6),
|
|
@@ -2,16 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module GoodJob
|
|
4
4
|
class BatchesFilter < BaseFilter
|
|
5
|
+
def job_count(batch)
|
|
6
|
+
job_counts.fetch(batch.id, 0)
|
|
7
|
+
end
|
|
8
|
+
|
|
5
9
|
def filtered_query(_filtered_params = params)
|
|
6
10
|
base_query
|
|
7
11
|
end
|
|
8
12
|
|
|
9
13
|
def query_for_records
|
|
10
|
-
|
|
14
|
+
base_query
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def default_base_query
|
|
14
|
-
GoodJob::BatchRecord.
|
|
18
|
+
GoodJob::BatchRecord.all
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def job_counts
|
|
24
|
+
@_job_counts ||= GoodJob::Job.where(batch_id: records.map(&:id)).group(:batch_id).count
|
|
15
25
|
end
|
|
16
26
|
end
|
|
17
27
|
end
|
|
@@ -7,8 +7,15 @@ export default class extends Controller {
|
|
|
7
7
|
}
|
|
8
8
|
static targets = ["value"]
|
|
9
9
|
|
|
10
|
+
#onLivePollRefresh = () => this.#fetch();
|
|
11
|
+
|
|
10
12
|
connect() {
|
|
11
13
|
this.#fetch();
|
|
14
|
+
document.addEventListener("good_job:live-poll-refreshed", this.#onLivePollRefresh);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnect() {
|
|
18
|
+
document.removeEventListener("good_job:live-poll-refreshed", this.#onLivePollRefresh);
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
async #fetch() {
|
|
@@ -56,8 +56,23 @@ export default class extends Controller {
|
|
|
56
56
|
const regionName = newElement.getAttribute('data-live-poll-region')
|
|
57
57
|
const originalElement = document.querySelector(`[data-live-poll-region="${regionName}"]`)
|
|
58
58
|
if (originalElement) {
|
|
59
|
-
originalElement
|
|
59
|
+
this.#restoreTabs(originalElement, newElement)
|
|
60
60
|
}
|
|
61
61
|
})
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
// Re-select any Bootstrap tabs that were active before a region is replaced
|
|
65
|
+
#restoreTabs(originalElement, newElement) {
|
|
66
|
+
const activeTargets = [...originalElement.querySelectorAll('.nav-link.active[data-bs-toggle]')]
|
|
67
|
+
.map((tab) => tab.getAttribute('data-bs-target') || tab.getAttribute('href'))
|
|
68
|
+
originalElement.replaceWith(newElement)
|
|
69
|
+
activeTargets.forEach((target) => {
|
|
70
|
+
const newTab = newElement.querySelector(`[data-bs-target="${target}"], [href="${target}"]`)
|
|
71
|
+
if (newTab) {
|
|
72
|
+
bootstrap.Tab.getOrCreateInstance(newTab).show()
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
document.dispatchEvent(new CustomEvent("good_job:live-poll-refreshed"))
|
|
77
|
+
}
|
|
63
78
|
}
|
|
@@ -88,6 +88,11 @@
|
|
|
88
88
|
min-width: 14rem;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/* Grid cells default to min-width:auto, so a wide header label would grow its column instead of wrapping. */
|
|
92
|
+
.performance-row > * {
|
|
93
|
+
min-width: 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
.performance-name {
|
|
92
97
|
overflow-wrap: anywhere;
|
|
93
98
|
white-space: normal;
|