plutonium 0.68.0 → 0.69.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
  3. data/CHANGELOG.md +20 -0
  4. data/SECURITY.md +1 -1
  5. data/app/assets/plutonium.css +3 -1
  6. data/config/locales/en/async.yml +11 -1
  7. data/docs/public/images/reference/async-progress-page.png +0 -0
  8. data/docs/public/images/reference/async-running-banner.png +0 -0
  9. data/docs/reference/behavior/async-interactions.md +62 -5
  10. data/docs/reference/generators/lite.md +30 -6
  11. data/docs/reference/i18n.md +1 -1
  12. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
  13. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
  14. data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
  15. data/gemfiles/rails_7.gemfile.lock +5 -5
  16. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  17. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  18. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
  19. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
  20. data/lib/plutonium/definition/presentable.rb +4 -1
  21. data/lib/plutonium/interaction/async/configuration.rb +15 -0
  22. data/lib/plutonium/interaction/async/context.rb +12 -2
  23. data/lib/plutonium/interaction/async/executor.rb +54 -0
  24. data/lib/plutonium/interaction/async/run.rb +128 -14
  25. data/lib/plutonium/interaction/async/run_definition.rb +5 -4
  26. data/lib/plutonium/interaction/async/run_policy.rb +5 -5
  27. data/lib/plutonium/interaction/response/redirect.rb +19 -7
  28. data/lib/plutonium/ui/breadcrumbs.rb +26 -8
  29. data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
  30. data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
  31. data/lib/plutonium/ui/layout/base.rb +0 -9
  32. data/lib/plutonium/ui/page/index.rb +23 -4
  33. data/lib/plutonium/version.rb +1 -1
  34. data/package.json +1 -1
  35. data/src/css/components.css +9 -0
  36. data/src/css/font_awesome.css +2328 -0
  37. data/src/css/plutonium.css +1 -0
  38. metadata +6 -2
@@ -0,0 +1,49 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-10-15-async-run-pre-check.md",
3
+ "tasks": [
4
+ {
5
+ "id": 0,
6
+ "subject": "Task 1: Run model: reject, reject_run!, rejected outcome, max_rejections",
7
+ "status": "completed",
8
+ "description": "**Goal:** `Run` can collect rejections during a check, persist them in one write, and report `outcome == \"rejected\"`.\n\n**Files:**\n- Modify: `lib/plutonium/interaction/async/configuration.rb`\n- Modify: `lib/plutonium/interaction/async/run.rb`\n- Modify: `config/locales/en/async.yml`\n- Test: `test/plutonium/interaction/async/run_test.rb`\n\n**Acceptance Criteria:**\n- [ ] `config.async_interactions.max_rejections` defaults to 100.\n- [ ] `reject` raises outside `collect_rejections`; inside, it stores up to `max_rejections` messages and counts the rest.\n- [ ] `reject_run!(messages, overflow:)` writes entries tagged `\"stage\" => \"pre_check\"`, a truncation entry when `overflow > 0`, and `state: \"failed\"` in a single UPDATE.\n- [ ] `outcome` is `\"rejected\"` for a failed run with a tagged entry; untagged failed runs still read `\"failed\"`.\n\n**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb` \u2192 0 failures, 0 errors\n\nSee plan section 'Task 1: Run model: reject, reject_run!, rejected outcome, max_rejections' for steps.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/interaction/async/configuration.rb\", \"lib/plutonium/interaction/async/run.rb\", \"config/locales/en/async.yml\", \"test/plutonium/interaction/async/run_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/run_test.rb\", \"acceptanceCriteria\": [\"max_rejections defaults to 100\", \"reject raises outside collect_rejections and caps stored messages\", \"reject_run! writes tagged entries and failed in one UPDATE\", \"outcome reads rejected only for tagged failed runs\"], \"requiresUserVerification\": false}\n```"
9
+ },
10
+ {
11
+ "id": 1,
12
+ "subject": "Task 2: Executor runs the check before the work",
13
+ "status": "completed",
14
+ "description": "**Goal:** The executor calls `pre_check` with the right arguments for the run's shape and stops before any work when it rejects.\n\n**Files:**\n- Modify: `lib/plutonium/interaction/async/executor.rb` (`perform_opaque`, `perform_targets`, new private methods)\n- Modify: `config/locales/en/async.yml`\n- Test: `test/plutonium/interaction/async/executor_test.rb`\n\n**Acceptance Criteria:**\n- [ ] Opaque and targeted runs whose check rejects nothing perform as before.\n- [ ] A rejection stops `perform` / `perform_on`; `progress_done` stays 0; `outcome == \"rejected\"`.\n- [ ] Every rejection is recorded; past `max_rejections`, one \"and N more\" entry with the exact remainder.\n- [ ] An exception raised in `pre_check` reads `failed`, not `rejected`.\n- [ ] A wrong-arity `pre_check` fails the run with a message naming the class and expected signature.\n- [ ] A resumed targeted run's check receives only the unhandled records.\n- [ ] A `:transactional` run is rejected without opening a transaction.\n\n**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb` \u2192 0 failures, 0 errors\n\nSee plan section 'Task 2: Executor runs the check before the work' for steps.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/interaction/async/executor.rb\", \"config/locales/en/async.yml\", \"test/plutonium/interaction/async/executor_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/async/executor_test.rb\", \"acceptanceCriteria\": [\"passing checks perform as before\", \"rejection stops work, progress_done 0, outcome rejected\", \"cap with exact remainder\", \"exception in check reads failed\", \"wrong arity names expected signature\", \"resumed run checks remaining records only\", \"transactional rejection opens no batch transaction\"], \"requiresUserVerification\": false}\n```",
15
+ "blockedBy": [
16
+ 0
17
+ ]
18
+ },
19
+ {
20
+ "id": 2,
21
+ "subject": "Task 3: Show rejected runs on the progress page and index",
22
+ "status": "completed",
23
+ "description": "**Goal:** A rejected run shows a warning \"Rejected\" badge, a \"Nothing was processed\" line, and its messages.\n\n**Files:**\n- Modify: `lib/plutonium/ui/interaction/async/run_progress.rb` (`BADGE_VARIANTS`, `render_panel`, new `render_rejected`)\n- Modify: `lib/plutonium/interaction/async/run_definition.rb` (both `colors:` hashes)\n- Modify: `config/locales/en/async.yml`\n- Test: `test/integration/admin_portal/async_run_progress_test.rb`\n\n**Acceptance Criteria:**\n- [ ] The show page renders a `pu-badge-warning` badge reading \"Rejected\", the line \"Nothing was processed\", and every rejection message.\n- [ ] A plain failed run does not show \"Nothing was processed\".\n- [ ] The index renders \"Rejected\" for the run.\n\n**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb` \u2192 0 failures, 0 errors\n\nSee plan section 'Task 3: Show rejected runs on the progress page and index' for steps.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/ui/interaction/async/run_progress.rb\", \"lib/plutonium/interaction/async/run_definition.rb\", \"config/locales/en/async.yml\", \"test/integration/admin_portal/async_run_progress_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_run_progress_test.rb\", \"acceptanceCriteria\": [\"warning Rejected badge, Nothing was processed, all messages on show page\", \"plain failed run lacks the line\", \"index shows Rejected\"], \"requiresUserVerification\": false}\n```",
24
+ "blockedBy": [
25
+ 0
26
+ ]
27
+ },
28
+ {
29
+ "id": 3,
30
+ "subject": "Task 4: Dummy CSV import with a pre_check, end to end",
31
+ "status": "completed",
32
+ "description": "**Goal:** A real interaction in the dummy app exercises dispatch \u2192 staged file \u2192 pre_check \u2192 rejected or completed, and backs the docs example.\n\n**Files:**\n- Create: `test/dummy/app/interactions/import_tasks_interaction.rb`\n- Modify: `test/dummy/app/policies/task_policy.rb` (after `archive_all_async?`)\n- Modify: `test/dummy/app/definitions/task_definition.rb` (after `action :archive_all_async`)\n- Create: `test/integration/admin_portal/async_import_pre_check_test.rb`\n\n**Acceptance Criteria:**\n- [ ] Posting a CSV with bad rows creates a run that, once executed, reads `rejected`, lists one message per bad row, and creates no tasks.\n- [ ] Posting a valid CSV creates the tasks and the run reads `completed`.\n- [ ] The rejected run's progress page shows the badge and messages.\n\n**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb` \u2192 0 failures, 0 errors\n\nSee plan section 'Task 4: Dummy CSV import with a pre_check, end to end' for steps.\n\n```json:metadata\n{\"files\": [\"test/dummy/app/interactions/import_tasks_interaction.rb\", \"test/dummy/app/models/task.rb\", \"test/dummy/app/policies/task_policy.rb\", \"test/dummy/app/definitions/task_definition.rb\", \"test/integration/admin_portal/async_import_pre_check_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/async_import_pre_check_test.rb\", \"acceptanceCriteria\": [\"bad CSV rejected whole with one message per bad row and no tasks\", \"valid CSV imported and completed\", \"progress page shows Rejected and messages\"], \"requiresUserVerification\": false}\n```",
33
+ "blockedBy": [
34
+ 1,
35
+ 2
36
+ ]
37
+ },
38
+ {
39
+ "id": 4,
40
+ "subject": "Task 5: Docs and skill",
41
+ "status": "completed",
42
+ "description": "**Goal:** The reference doc and the shipped skill explain `pre_check`, `reject`, the `rejected` outcome, `max_rejections`, and the heartbeat requirement, with samples that match the dummy app.\n\n**Files:**\n- Modify: `docs/reference/behavior/async-interactions.md`\n- Modify: `.claude/skills/plutonium-async-interactions/SKILL.md`\n\n**Acceptance Criteria:**\n- [ ] A \"Checking input before processing\" section after \"### Files\" in the doc, and after \"## Attributes and files\" in the skill.\n- [ ] `max_rejections` in the doc's Enabling config block.\n- [ ] \"Outcome vs state\" documents `rejected`.\n- [ ] Both heartbeat warnings mention `pre_check`.\n- [ ] No em dashes; samples match `ImportTasksInteraction`.\n- [ ] `yarn docs:build` passes.\n\n**Verify:** `yarn docs:build` \u2192 completes without dead-link errors\n\nSee plan section 'Task 5: Docs and skill' for steps.\n\n```json:metadata\n{\"files\": [\"docs/reference/behavior/async-interactions.md\", \".claude/skills/plutonium-async-interactions/SKILL.md\"], \"verifyCommand\": \"yarn docs:build\", \"acceptanceCriteria\": [\"new section in doc and skill\", \"max_rejections in config block\", \"rejected in Outcome vs state\", \"heartbeat notes mention pre_check\", \"no em dashes\", \"docs build passes\"], \"requiresUserVerification\": false}\n```",
43
+ "blockedBy": [
44
+ 3
45
+ ]
46
+ }
47
+ ],
48
+ "lastUpdated": "2026-10-15T04:38:35.300061+00:00"
49
+ }
@@ -0,0 +1,208 @@
1
+ # Async run pre_check
2
+
3
+ **Status:** approved design, not yet planned
4
+ **Date:** 2026-10-15
5
+ **Area:** `lib/plutonium/interaction/async/`
6
+
7
+ ## Problem
8
+
9
+ An async run has no stage for refusing its input. Cheap file checks already run
10
+ synchronously (the uploader's `Attacher.validate` rules run during interaction
11
+ validation, so a wrong type or size fails the form). Checks too slow or too deep
12
+ for a request have nowhere to go: parsing a 50k-row CSV, checking its headers,
13
+ validating every row, looking up referenced records. Today the only option is to
14
+ raise inside `perform`, which reads as a crash partway through and gives the user
15
+ one problem per attempt.
16
+
17
+ ## Decisions
18
+
19
+ | Question | Decision |
20
+ |---|---|
21
+ | After the check passes | Processing starts in the same job. No confirmation step. |
22
+ | Reporting problems | Collect every problem, then reject the run if any were recorded. |
23
+ | How a rejected run reads | `state` stays `failed`; `outcome` reads `rejected`. |
24
+ | Declaration style | A plain method in the run class, like `perform` / `perform_on`. |
25
+ | Cap on stored problems | Configurable, `config.async_interactions.max_rejections`, default 100. |
26
+
27
+ ## API
28
+
29
+ ```ruby
30
+ async do
31
+ def pre_check
32
+ attachment(:import_file).open do |f|
33
+ CSV.foreach(f, headers: true).with_index(2) do |row, line|
34
+ reject "line #{line}: missing email" if row["email"].blank?
35
+ heartbeat! if (line % 1_000).zero?
36
+ end
37
+ end
38
+ end
39
+
40
+ def perform
41
+ # only runs if pre_check rejected nothing
42
+ end
43
+ end
44
+ ```
45
+
46
+ A targeted run's check receives the resolved, authorized records:
47
+
48
+ ```ruby
49
+ async do
50
+ def pre_check(invoices)
51
+ currencies = invoices.map(&:currency).uniq
52
+ reject "mixed currencies: #{currencies.join(", ")}" if currencies.size > 1
53
+ end
54
+
55
+ def perform_on(invoice)
56
+ invoice.reissue!
57
+ end
58
+ end
59
+ ```
60
+
61
+ ### `Run#pre_check` / `Run#pre_check(records)`
62
+
63
+ - Optional. The base class defines a no-op, so existing runs are unaffected.
64
+ - Invoked through `send`, matching how `perform` / `perform_on` are invoked, so a
65
+ private definition works.
66
+ - Arguments follow the run's shape: none for opaque work, the resolved records for
67
+ targeted work. A definition with the wrong arity fails the run with a message
68
+ naming the class and the expected signature.
69
+
70
+ ### `Run#reject(message)`
71
+
72
+ - Records one problem and returns, so the check can report every bad row in one
73
+ pass.
74
+ - Valid only while `pre_check` runs. Called from `perform`, `perform_on`, or
75
+ anywhere else it raises, since a rejection nothing reads would be lost.
76
+
77
+ ## Execution
78
+
79
+ In `Async::Executor`, after `claim!`:
80
+
81
+ - **Opaque:** `pre_check` → `perform` → `finish!`
82
+ - **Targeted:** resolve targets → `record_unresolved` and the existing partial-batch
83
+ refusal for `:halt` / `:transactional` → `pre_check(resolved.records)` → the
84
+ per-target loop (or the transactional batch) → `finish!`
85
+
86
+ `pre_check` sees only the records the loop is about to process. Missing or
87
+ unauthorized ids are already recorded failures by then.
88
+
89
+ After `pre_check` returns:
90
+
91
+ - **Nothing rejected:** processing starts.
92
+ - **Anything rejected:** the executor calls `Run#reject_run!(messages, overflow:)`. `perform` /
93
+ `perform_on` never run, no target is touched, and `progress_done` stays 0.
94
+
95
+ An exception raised inside `pre_check` takes the existing failure path in
96
+ `Executor#call`. That run reads `failed`, not `rejected`: "your input is wrong" and
97
+ "the check itself broke" are different reports.
98
+
99
+ ## Recording
100
+
101
+ No migration.
102
+
103
+ Each rejection becomes an `errors_log` entry:
104
+
105
+ ```ruby
106
+ {"target_id" => nil, "message" => "line 12: missing email", "stage" => "pre_check"}
107
+ ```
108
+
109
+ - A nil `target_id` is already the run-level sentinel, so progress counters and
110
+ `handled_target_ids` are untouched.
111
+ - `stage` is a new key. Entries written before it have none, and current readers
112
+ ignore unknown keys.
113
+
114
+ `Run#reject_run!(messages, overflow:)` writes the entries and `state: "failed"`,
115
+ `finished_at`, `last_activity_at` in one `update!`, so a crash cannot leave a
116
+ failed run without its reasons or reasons on a run still marked running (the same
117
+ reasoning as `record_target_failures!`).
118
+
119
+ ### Cap
120
+
121
+ - Only the first `max_rejections` messages are stored.
122
+ - When more were rejected, one extra entry (also `stage: "pre_check"`) records the
123
+ remainder: "and 4900 more problems were found"
124
+ (`plutonium.async.executor.rejections_truncated`, `one` / `other`).
125
+ - `Async::Configuration#max_rejections`, default `100`. Not added to the install
126
+ generator's initializer template; documented with the other settings.
127
+ - The cap is applied in `reject`: it stores at most `max_rejections` messages and
128
+ counts the rest, so memory stays bounded and the remainder is exact.
129
+ `reject_run!` receives the stored messages and the overflow count.
130
+
131
+ ## Outcome and display
132
+
133
+ - `Run#outcome` returns `"rejected"` when `state == "failed"` and any `errors_log`
134
+ entry has `"stage" => "pre_check"`. A completed run can never carry such an
135
+ entry.
136
+ - `state` stays `failed`, so `ReapJob`, `in_progress`, the running banner and the
137
+ poll-stop logic are unchanged.
138
+ - **Badge:** `rejected: :warning` in the `display :outcome` and `column :outcome`
139
+ declarations in `RunDefinition`, and in `BADGE_VARIANTS` in
140
+ `lib/plutonium/ui/interaction/async/run_progress.rb`. Warning, not danger: the
141
+ input was refused and nothing broke.
142
+ - **Badge text:** the existing `Plutonium::Translation.value_label` lookup
143
+ (`plutonium.values.*`), falling back to "Rejected".
144
+ - **Progress panel:** a rejected run shows "Nothing was processed"
145
+ (`plutonium.async.progress.rejected`) above the failures list, so "0 of 50
146
+ targets" does not read as a run that died at its first target.
147
+ - **Failures list:** unchanged. Entries with a nil `target_id` already render
148
+ without the `#id:` prefix.
149
+
150
+ ## Edge cases
151
+
152
+ - **Resume after a reap:** A resumed opaque run re-runs `pre_check` then
153
+ `perform` from the start (as opaque work already does). A resumed targeted run
154
+ skips the check if an earlier pass applied a target (a handled id with no
155
+ failure entry; failed targets do not count), since targets are only performed
156
+ after the check passes. Otherwise it checks only the unhandled records.
157
+ Skipping keeps a half-applied run from being rejected as "nothing was
158
+ processed".
159
+ - **`:transactional`:** `pre_check` runs before the transaction opens. A rejected
160
+ batch never opens one.
161
+ - **Heartbeat:** the executor writes nothing while `pre_check` runs. A long check
162
+ must call `heartbeat!`, as opaque `perform` must today.
163
+ - **Reaped mid-check:** `heartbeat!` and `reject_run!` both raise
164
+ `ActiveRecord::StaleObjectError` against a superseded row. `Executor#superseded?`
165
+ already turns that into "no longer mine".
166
+
167
+ ## Testing
168
+
169
+ **Executor (`test/plutonium/interaction/async/executor_test.rb`)**
170
+ - Opaque and targeted runs pass the check, then perform.
171
+ - A rejection stops `perform` / `perform_on`; `progress_done` stays 0; outcome is
172
+ `rejected`.
173
+ - Several rejections are all recorded.
174
+ - The cap: `max_rejections` respected, plus the "and N more" entry with the exact
175
+ remainder.
176
+ - An exception raised in `pre_check` reads `failed`, not `rejected`.
177
+ - A wrong-arity `pre_check` fails with a message naming the expected signature.
178
+ - `reject` outside `pre_check` raises.
179
+ - A resumed targeted run's check receives only the remaining records.
180
+ - A `:transactional` run is rejected without opening a transaction.
181
+
182
+ **Run (`test/plutonium/interaction/async/run_test.rb`)**
183
+ - `outcome` reads `rejected` from tagged entries; entries without `stage` are
184
+ unaffected.
185
+ - `reject_run!` writes entries and state in one update.
186
+
187
+ **Integration (`test/integration/admin_portal/`)**
188
+ - The progress page shows the Rejected badge, the "Nothing was processed" line and
189
+ the messages; the index shows the badge.
190
+
191
+ **Dummy app**
192
+ - An opaque CSV import interaction in the admin portal, backing the integration
193
+ test and the docs example. Hand-authored: there is no interaction generator.
194
+
195
+ ## Docs
196
+
197
+ - `docs/reference/behavior/async-interactions.md`: a "Checking input before
198
+ processing" section; `max_rejections` with the other settings.
199
+ - `.claude/skills/plutonium-async-interactions/SKILL.md`: a matching section, and
200
+ `pre_check` added to the heartbeat warning in Critical.
201
+ - Every sample checked against the implementation before it is published.
202
+
203
+ ## Out of scope
204
+
205
+ - A confirmation step between check and processing.
206
+ - Excluding individual targets from the check and processing the rest (the
207
+ `:continue` failure policy covers per-target failures).
208
+ - Progress reporting for opaque work.
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- plutonium (0.66.0)
12
+ plutonium (0.68.0)
13
13
  action_policy (~> 0.7.0)
14
14
  csv
15
15
  listen (~> 3.8)
@@ -20,7 +20,7 @@ PATH
20
20
  phlex-tabler_icons
21
21
  phlexi-display (>= 0.2.0)
22
22
  phlexi-field (>= 0.2.0)
23
- phlexi-form (>= 0.14.3)
23
+ phlexi-form (>= 0.14.4)
24
24
  phlexi-menu (>= 0.4.1)
25
25
  phlexi-table (>= 0.2.0)
26
26
  rabl (~> 0.17.0)
@@ -271,7 +271,7 @@ GEM
271
271
  fiber-local
272
272
  phlex (~> 2.0)
273
273
  zeitwerk
274
- phlexi-form (0.14.3)
274
+ phlexi-form (0.14.4)
275
275
  activesupport
276
276
  phlex (~> 2.0)
277
277
  phlexi-field (~> 0.2.0)
@@ -608,10 +608,10 @@ CHECKSUMS
608
608
  phlex-tabler_icons (0.3.0) sha256=67d80cc09c619646cff171a620fa805cb49543625c3586a947cd752d44d2858e
609
609
  phlexi-display (0.2.0) sha256=73e22d01e7204a3b94dcb21432477982bfa5162926c11bacd7d48930114236de
610
610
  phlexi-field (0.2.0) sha256=ecb509cc2c0e0559f52651cac8d5dd2fca4bda3878cb7343ea7b298c760e7bca
611
- phlexi-form (0.14.3) sha256=47d69c4a49ba22279ba541b9b110e81d62acc4602513f21bb5c3d29fa9c8d5ca
611
+ phlexi-form (0.14.4)
612
612
  phlexi-menu (0.4.1) sha256=f6eeb1eb9597caadeee33a53f1520b3613a6ac616cf1e395e9d734a412895fb2
613
613
  phlexi-table (0.2.0) sha256=73bee135adbe73fcdb6fdd2672444373556dd9e6bba61e7b5b20ca8b7a1f822c
614
- plutonium (0.66.0)
614
+ plutonium (0.68.0)
615
615
  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
616
616
  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
617
617
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- plutonium (0.66.0)
12
+ plutonium (0.68.0)
13
13
  action_policy (~> 0.7.0)
14
14
  csv
15
15
  listen (~> 3.8)
@@ -20,7 +20,7 @@ PATH
20
20
  phlex-tabler_icons
21
21
  phlexi-display (>= 0.2.0)
22
22
  phlexi-field (>= 0.2.0)
23
- phlexi-form (>= 0.14.3)
23
+ phlexi-form (>= 0.14.4)
24
24
  phlexi-menu (>= 0.4.1)
25
25
  phlexi-table (>= 0.2.0)
26
26
  rabl (~> 0.17.0)
@@ -252,7 +252,7 @@ GEM
252
252
  fiber-local
253
253
  phlex (~> 2.0)
254
254
  zeitwerk
255
- phlexi-form (0.14.3)
255
+ phlexi-form (0.14.4)
256
256
  activesupport
257
257
  phlex (~> 2.0)
258
258
  phlexi-field (~> 0.2.0)
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- plutonium (0.66.0)
12
+ plutonium (0.68.0)
13
13
  action_policy (~> 0.7.0)
14
14
  csv
15
15
  listen (~> 3.8)
@@ -20,7 +20,7 @@ PATH
20
20
  phlex-tabler_icons
21
21
  phlexi-display (>= 0.2.0)
22
22
  phlexi-field (>= 0.2.0)
23
- phlexi-form (>= 0.14.3)
23
+ phlexi-form (>= 0.14.4)
24
24
  phlexi-menu (>= 0.4.1)
25
25
  phlexi-table (>= 0.2.0)
26
26
  rabl (~> 0.17.0)
@@ -254,7 +254,7 @@ GEM
254
254
  fiber-local
255
255
  phlex (~> 2.0)
256
256
  zeitwerk
257
- phlexi-form (0.14.3)
257
+ phlexi-form (0.14.4)
258
258
  activesupport
259
259
  phlex (~> 2.0)
260
260
  phlexi-field (~> 0.2.0)
@@ -10,10 +10,12 @@ module Pu
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Install a nightly SqliteMaintenanceJob (PRAGMA optimize + VACUUM)"
13
+ desc "Install a nightly SqliteMaintenanceJob (PRAGMA optimize + VACUUM) and schedule SolidQueue finished-job cleanup"
14
14
 
15
15
  class_option :schedule, type: :string, default: "every day at 3:30am",
16
16
  desc: "Cron-style schedule for the maintenance job"
17
+ class_option :clear_finished_schedule, type: :string, default: "every hour at minute 12",
18
+ desc: "Schedule for clearing finished SolidQueue jobs"
17
19
 
18
20
  def start
19
21
  template "app/jobs/sqlite_maintenance_job.rb"
@@ -22,6 +24,11 @@ module Pu
22
24
  unless add_recurring_tasks(maintenance_task_yaml, marker: "sqlite_maintenance")
23
25
  log :skip, "could not schedule (config/recurring.yml missing or already scheduled)"
24
26
  end
27
+ # Rails 8 apps ship this task already; the marker matches its command
28
+ # so we never schedule it twice under a different key.
29
+ unless add_recurring_tasks(clear_finished_jobs_task_yaml, marker: "clear_finished_in_batches")
30
+ log :skip, "finished-job cleanup not scheduled (config/recurring.yml missing or already scheduled)"
31
+ end
25
32
  else
26
33
  log :info, "solid_queue not found — job installed but not scheduled. Add a 'sqlite_maintenance' entry to your scheduler."
27
34
  end
@@ -40,6 +47,18 @@ module Pu
40
47
  description: "VACUUM + PRAGMA optimize across SQLite databases"
41
48
  YAML
42
49
  end
50
+
51
+ # SolidQueue keeps finished jobs (preserve_finished_jobs defaults to true)
52
+ # and only deletes them when asked. Without this the queue database grows
53
+ # without bound.
54
+ def clear_finished_jobs_task_yaml
55
+ <<~YAML
56
+ clear_solid_queue_finished_jobs:
57
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
58
+ schedule: "#{options[:clear_finished_schedule]}"
59
+ description: "Delete SolidQueue jobs finished longer ago than clear_finished_jobs_after"
60
+ YAML
61
+ end
43
62
  end
44
63
  end
45
64
  end
@@ -26,6 +26,12 @@ class SqliteMaintenanceJob < ApplicationJob
26
26
  # churning DB sits at a steady-state size without nightly reclamation.
27
27
  VACUUM_DBS = %w[primary errors rails_pulse].freeze
28
28
 
29
+ # VACUUM holds that exclusive lock for as long as the rewrite takes, which on
30
+ # a large primary is long enough for inbound requests to time out waiting.
31
+ # WAL reuses freelist pages anyway, so the rewrite only pays off once a real
32
+ # share of the file is free, usually after a bulk delete.
33
+ VACUUM_MIN_FREE_RATIO = 0.1
34
+
29
35
  def perform
30
36
  OPTIMIZE_DBS.each { |db_name| run_maintenance(db_name) }
31
37
  end
@@ -46,6 +52,7 @@ class SqliteMaintenanceJob < ApplicationJob
46
52
  conn.execute("PRAGMA optimize")
47
53
 
48
54
  next unless VACUUM_DBS.include?(db_name)
55
+ next unless vacuum_worthwhile?(conn, db_name)
49
56
 
50
57
  Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name}" }
51
58
  started = Time.current
@@ -53,8 +60,18 @@ class SqliteMaintenanceJob < ApplicationJob
53
60
  Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name} done in #{(Time.current - started).round(2)}s" }
54
61
  end
55
62
  rescue => e
56
- Rails.error.report(e, context: {db: db_name, action: "sqlite_maintenance"})
63
+ Rails.error.report(e, context: {db: db_name, action: "sqlite_maintenance"}, source: "sqlite_maintenance")
57
64
  ensure
58
65
  MaintenanceConnection.remove_connection
59
66
  end
67
+
68
+ def vacuum_worthwhile?(conn, db_name)
69
+ free = conn.select_value("PRAGMA freelist_count").to_i
70
+ total = conn.select_value("PRAGMA page_count").to_i
71
+ ratio = total.zero? ? 0 : free.fdiv(total)
72
+ return true if ratio >= VACUUM_MIN_FREE_RATIO
73
+
74
+ Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name} skipped, #{(ratio * 100).round(1)}% free" }
75
+ false
76
+ end
60
77
  end
@@ -36,8 +36,11 @@ module Plutonium
36
36
  # Returns the label for the interaction.
37
37
  #
38
38
  # @return [String] The label defined in the presentation metadata or a default generated from the class name.
39
+ # The conventional "Interaction" suffix is dropped: ImportPostsInteraction reads "Import Posts".
40
+ # Only a suffix: a class named just Interaction keeps its name rather than going blank.
39
41
  def label
40
- Plutonium::Translation.resolve(presentation_metadata[:label]) || name.demodulize.titleize
42
+ Plutonium::Translation.resolve(presentation_metadata[:label]) ||
43
+ name.demodulize.sub(/(?<=.)Interaction\z/, "").titleize
41
44
  end
42
45
 
43
46
  # Returns the icon for the interaction.
@@ -27,10 +27,25 @@ module Plutonium
27
27
  # This is the knob for which backend does that, for runs alone.
28
28
  attr_accessor :attachment_backend
29
29
 
30
+ # @return [Integer] how many pre_check rejections a run stores. The rest
31
+ # are counted and reported as one "and N more" entry, so a file that is
32
+ # bad on every row cannot write tens of thousands of entries into the
33
+ # errors_log column (and onto the progress page). Must be a positive Integer.
34
+ attr_accessor :max_rejections
35
+
36
+ # @return [ActiveSupport::Duration] how long a run stays in the running
37
+ # banner after it finishes, for the user who started it. A dispatch
38
+ # sends that user back to the index, and a short run (or one its
39
+ # pre_check rejected) settles before the page loads; without this it
40
+ # would never show there at all.
41
+ attr_accessor :recent_runs_window
42
+
30
43
  def initialize
31
44
  @enabled = false
32
45
  @queue = :default
33
46
  @stall_after = 1.hour
47
+ @max_rejections = 100
48
+ @recent_runs_window = 10.minutes
34
49
  end
35
50
  end
36
51
  end
@@ -225,9 +225,14 @@ module Plutonium
225
225
  #
226
226
  # @return [Targets]
227
227
  def targets
228
+ unless run.targeted?
229
+ raise UnresolvableError,
230
+ "run #{run.id} (#{run.class}) is not a targeted run; it has no targets to resolve"
231
+ end
232
+
228
233
  unless run.target_type
229
234
  raise UnresolvableError,
230
- "run #{run.id} (#{run.class}) has no target_type; it is not a targeted run"
235
+ "run #{run.id} (#{run.class}) has no target_type; it cannot resolve its targets"
231
236
  end
232
237
 
233
238
  key = target_class.primary_key
@@ -380,8 +385,13 @@ module Plutonium
380
385
  #
381
386
  # Only meaningful for a targeted run — untargeted work has no target
382
387
  # resource and therefore no policy to pin.
388
+ #
389
+ # Keyed on Run#targeted?, not on target_type: an opaque run dispatched
390
+ # from a resource still records target_type (it is what puts the run in
391
+ # that resource's banner), but it resolves no targets, so it pins no
392
+ # policy and there is nothing here to verify.
383
393
  def verify_policy!
384
- return if run.target_type.nil?
394
+ return unless run.targeted? && run.target_type
385
395
 
386
396
  # A targeted run with no recorded policy is not "unasserted", it is
387
397
  # unverifiable: there is nothing to compare today's lookup against, and
@@ -190,6 +190,8 @@ module Plutonium
190
190
  # the framework is meant to call, and it must not change what the
191
191
  # executor does with it.
192
192
  def perform_opaque
193
+ return unless pre_check_passed?
194
+
193
195
  run.send(:perform)
194
196
  run.finish!
195
197
  end
@@ -200,6 +202,17 @@ module Plutonium
200
202
 
201
203
  return refuse_partial_batch(resolved) if unresolved?(resolved) && !continue?
202
204
 
205
+ # A resumed run whose earlier pass already applied a target was
206
+ # approved by that pass's check: targets are only performed after it
207
+ # passes. Re-checking the remainder could reject a run that is
208
+ # half-applied and report "nothing was processed". Opaque runs restart
209
+ # from scratch, so they re-check.
210
+ #
211
+ # After record_unresolved, so a rejected :continue run still reports
212
+ # the targets that had gone; those count toward progress_done, though
213
+ # nothing was performed.
214
+ return unless run.any_target_applied? || pre_check_passed?(resolved.records)
215
+
203
216
  if transactional?
204
217
  perform_all_or_nothing(resolved.records)
205
218
  run.finish!
@@ -217,6 +230,47 @@ module Plutonium
217
230
  end
218
231
  end
219
232
 
233
+ # Runs the run's pre_check with #reject live, and fails the run with
234
+ # whatever it rejected. Before any work: a rejected run touches no
235
+ # target. Outside the :transactional batch, which never opens for a
236
+ # rejected run.
237
+ #
238
+ # An exception from the check is NOT a rejection. It propagates to #call
239
+ # and fails the run the ordinary way, so "your input is wrong" and "the
240
+ # check itself broke" stay distinguishable (Run#outcome).
241
+ #
242
+ # send, for the reason #perform_opaque gives.
243
+ #
244
+ # @return [Boolean] whether the work may go ahead
245
+ def pre_check_passed?(*args)
246
+ verify_pre_check_arity!(args.size)
247
+
248
+ messages, overflow = run.collect_rejections { run.send(:pre_check, *args) }
249
+ return true if messages.empty? && overflow.zero?
250
+
251
+ run.reject_run!(messages, overflow: overflow)
252
+ false
253
+ end
254
+
255
+ # A targeted check declared opaque-style (or the reverse) would raise a
256
+ # bare "wrong number of arguments" from inside the executor, naming
257
+ # neither the class nor the fix.
258
+ #
259
+ # Raised as NotImplementedError ("the run class is written wrong"), which
260
+ # #call records like any other failure.
261
+ def verify_pre_check_arity!(given)
262
+ params = run.method(:pre_check).parameters.group_by(&:first)
263
+ required = params.fetch(:req, []).size
264
+ positional = required + params.fetch(:opt, []).size
265
+ fits = params[:keyreq].nil? && required <= given && (params.key?(:rest) || positional >= given)
266
+ return if fits
267
+
268
+ raise NotImplementedError, I18n.t(
269
+ "plutonium.async.executor.pre_check_arity.#{run.targeted? ? "targeted" : "opaque"}",
270
+ run_class: run.class
271
+ )
272
+ end
273
+
220
274
  # @return [Integer, nil] how many records were never attempted, if the
221
275
  # failure policy stopped the loop early — nil if it ran to the end
222
276
  def perform_each(records)