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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
- data/CHANGELOG.md +20 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +3 -1
- data/config/locales/en/async.yml +11 -1
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/reference/behavior/async-interactions.md +62 -5
- data/docs/reference/generators/lite.md +30 -6
- data/docs/reference/i18n.md +1 -1
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
- data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
- data/gemfiles/rails_7.gemfile.lock +5 -5
- data/gemfiles/rails_8.0.gemfile.lock +3 -3
- data/gemfiles/rails_8.1.gemfile.lock +3 -3
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
- data/lib/plutonium/definition/presentable.rb +4 -1
- data/lib/plutonium/interaction/async/configuration.rb +15 -0
- data/lib/plutonium/interaction/async/context.rb +12 -2
- data/lib/plutonium/interaction/async/executor.rb +54 -0
- data/lib/plutonium/interaction/async/run.rb +128 -14
- data/lib/plutonium/interaction/async/run_definition.rb +5 -4
- data/lib/plutonium/interaction/async/run_policy.rb +5 -5
- data/lib/plutonium/interaction/response/redirect.rb +19 -7
- data/lib/plutonium/ui/breadcrumbs.rb +26 -8
- data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
- data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
- data/lib/plutonium/ui/layout/base.rb +0 -9
- data/lib/plutonium/ui/page/index.rb +23 -4
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +9 -0
- data/src/css/font_awesome.css +2328 -0
- data/src/css/plutonium.css +1 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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]) ||
|
|
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
|
|
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
|
|
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)
|