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
data/config/locales/en/async.yml
CHANGED
|
@@ -7,8 +7,11 @@ en:
|
|
|
7
7
|
async:
|
|
8
8
|
run:
|
|
9
9
|
label: "%{name} #%{id}"
|
|
10
|
+
interaction: "Action"
|
|
11
|
+
progress: "%{done} / %{total}"
|
|
10
12
|
target_type: "Target type"
|
|
11
13
|
enqueue_failed: "could not be enqueued for execution"
|
|
14
|
+
reject_outside_pre_check: "%{run_class}#reject is only available inside pre_check"
|
|
12
15
|
executor:
|
|
13
16
|
target_missing: "Target %{id} is no longer available"
|
|
14
17
|
target_unauthorized: "Target %{id} is no longer permitted by %{action}"
|
|
@@ -17,6 +20,12 @@ en:
|
|
|
17
20
|
one: "stopped at the first target failure (%{policy} policy); %{count} target was not attempted"
|
|
18
21
|
other: "stopped at the first target failure (%{policy} policy); %{count} targets were not attempted"
|
|
19
22
|
partial_batch_refused: "%{unresolved} of %{total} targets could not be resolved; a %{policy} run does not apply a partial batch"
|
|
23
|
+
pre_check_arity:
|
|
24
|
+
targeted: "%{run_class}#pre_check must take the records: define pre_check(records) on a run with perform_on"
|
|
25
|
+
opaque: "%{run_class}#pre_check must take no arguments: define pre_check on a run with perform"
|
|
26
|
+
rejections_truncated:
|
|
27
|
+
one: "and %{count} more problem was found"
|
|
28
|
+
other: "and %{count} more problems were found"
|
|
20
29
|
context:
|
|
21
30
|
initiator_missing: "run %{run} has no initiator (%{type}#%{id} no longer exists)"
|
|
22
31
|
tenant_missing: "run %{run} was scoped to %{type}#%{id}, which no longer exists; refusing to resolve targets without a tenant"
|
|
@@ -25,10 +34,11 @@ en:
|
|
|
25
34
|
targets: "%{done} of %{total} targets (%{percent}%)"
|
|
26
35
|
working: "Working…"
|
|
27
36
|
no_total: "No progress total was recorded"
|
|
28
|
-
failures: "Failures"
|
|
29
37
|
error_count:
|
|
30
38
|
one: "%{count} error"
|
|
31
39
|
other: "%{count} errors"
|
|
32
40
|
target_failure: "#%{target_id}: %{message}"
|
|
41
|
+
rejected: "Nothing was processed"
|
|
33
42
|
banner:
|
|
34
43
|
view_progress: "View progress"
|
|
44
|
+
view_run: "View run"
|
|
Binary file
|
|
Binary file
|
|
@@ -26,6 +26,8 @@ Plutonium.configure do |config|
|
|
|
26
26
|
config.async_interactions.enabled = true
|
|
27
27
|
config.async_interactions.queue = :default # ActiveJob queue for run jobs
|
|
28
28
|
config.async_interactions.stall_after = 1.hour # see Stalled runs, below
|
|
29
|
+
config.async_interactions.max_rejections = 100 # pre_check problems stored per run
|
|
30
|
+
config.async_interactions.recent_runs_window = 10.minutes # finished runs stay in your banner this long
|
|
29
31
|
end
|
|
30
32
|
```
|
|
31
33
|
|
|
@@ -123,6 +125,58 @@ Validating means staging first, since Shrine validates an assigned cached file,
|
|
|
123
125
|
|
|
124
126
|
Where no `backend:` is declared: `config.async_interactions.attachment_backend`, then `config.attachment_backend`, then auto-detection (active_shrine loaded → Shrine, else ActiveStorage). Same layering wizards use.
|
|
125
127
|
|
|
128
|
+
### Checking input before processing
|
|
129
|
+
|
|
130
|
+
Validations on the interaction run in the request, so they suit cheap checks (an uploader's size and type rules already fail the form there). A check too slow for a request, like reading every row of a large file, goes in the run's `pre_check`. It runs in the job, before any work, and refuses the input with `reject`:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
require "csv"
|
|
134
|
+
|
|
135
|
+
class ImportTasks < ResourceInteraction
|
|
136
|
+
attribute :import_file
|
|
137
|
+
input :import_file, as: :file
|
|
138
|
+
|
|
139
|
+
async do
|
|
140
|
+
def pre_check
|
|
141
|
+
each_row do |row, line|
|
|
142
|
+
reject "line #{line}: title is missing" if row["title"].blank?
|
|
143
|
+
end
|
|
144
|
+
rescue CSV::MalformedCSVError => e
|
|
145
|
+
reject "the file is not valid CSV: #{e.message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# One transaction: an opaque run restarts from scratch after a reap, so a
|
|
149
|
+
# partial import would otherwise be imported twice.
|
|
150
|
+
def perform
|
|
151
|
+
Task.transaction { each_row { |row, _line| Task.create!(title: row["title"]) } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Read whole, as UTF-8 without Excel's byte-order mark: the stored file
|
|
157
|
+
# comes back as binary, and a BOM would become part of the first header.
|
|
158
|
+
def each_row
|
|
159
|
+
text = attachment(:import_file).download.force_encoding(Encoding::UTF_8).delete_prefix("\uFEFF")
|
|
160
|
+
CSV.parse(text, headers: true).each.with_index(2) do |row, line|
|
|
161
|
+
yield row, line unless row.fields.all?(&:blank?)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Rows are numbered from 2 so a message matches the line a spreadsheet shows (the header is line 1), and blank lines are skipped without shifting the numbers after them. The dummy app's `ImportTasksInteraction` is the full version, with a column check, an empty-file check and status validation.
|
|
169
|
+
|
|
170
|
+
- `reject` records a problem and returns, so one pass reports every bad row. It does not stop the check: `return` explicitly where nothing after it would make sense, such as a missing column.
|
|
171
|
+
- If anything was rejected, the run ends `failed` with `outcome == "rejected"`, the problems listed on its progress page, and `perform` / `perform_on` never runs. The page shows a warning "Rejected" badge and "Nothing was processed". On a `:continue` run, targets that had gone missing or lost permission are recorded before the check runs, so they still appear in the list (and count toward `progress_done`) even though nothing was performed.
|
|
172
|
+
- A run with `perform_on` declares `pre_check(records)` and receives the resolved, authorized records the loop is about to process. A `pre_check` with the wrong arity fails the run with a message naming the signature it expects.
|
|
173
|
+
- An exception raised inside `pre_check` fails the run the ordinary way (`outcome == "failed"`): the check broke, the input was not judged.
|
|
174
|
+
- `reject` called outside `pre_check` raises.
|
|
175
|
+
- Only the first `config.async_interactions.max_rejections` problems (default 100) are stored, followed by one "and N more problems were found" entry.
|
|
176
|
+
- The check runs again when a stalled run is resumed, so it must only read. An opaque run always re-checks. A resumed targeted run checks the records it has left, unless an earlier pass already applied a target (that pass's check passed, so it is not repeated).
|
|
177
|
+
- A targeted check receives the records as loaded when the run resolved its targets. `perform_on` gets each record re-read just before it runs, so a verdict based on record state can be stale by then; re-check anything that must hold at the moment of the work inside `perform_on`.
|
|
178
|
+
- A long check must call `heartbeat!` (see [Long work must say it is alive](#long-work-must-say-it-is-alive)).
|
|
179
|
+
|
|
126
180
|
### Sharing one run across interactions
|
|
127
181
|
|
|
128
182
|
Pass a class instead of a block when several interactions do the same kind of work:
|
|
@@ -175,7 +229,9 @@ run.state # "completed"
|
|
|
175
229
|
run.outcome # "completed_with_errors" (state == "completed" && errors_log.any?)
|
|
176
230
|
```
|
|
177
231
|
|
|
178
|
-
|
|
232
|
+
A run whose `pre_check` rejected its input reads `outcome == "rejected"` (its `state` is `"failed"`, and nothing was processed). A run that failed while working reads `"failed"`.
|
|
233
|
+
|
|
234
|
+
The progress page, the table's `outcome` column, and the running banner's entries for finished runs render `outcome`, never bare `state`.
|
|
179
235
|
|
|
180
236
|
## Registering the Run resource
|
|
181
237
|
|
|
@@ -207,17 +263,18 @@ A registered run resource gets, for free:
|
|
|
207
263
|
|
|
208
264
|
- **A progress page.** The show page IS the progress page. It self-refreshes via polling (not ActionCable) while `state` is `pending`/`running`, and stops carrying the poll once the run settles, so a finished run is a static page, not a background request per viewer.
|
|
209
265
|
- **A self-refreshing index.** The runs index polls on the same terms as the progress page: one frame around the whole collection, armed only while some run is still working, disarmed the moment none is. One request per interval regardless of page size, and it re-fetches the URL you are on, so filters, sort and page survive the refresh. A frame per row is not an option: `turbo-frame` is not in the content model of `tr`, so the parser hoists it out of the table before Turbo sees it.
|
|
210
|
-
- **A running banner.** Any OTHER resource's index page lists runs currently in progress against it, above the collection, so a user who dispatched a bulk action and navigated away can find it again. Scoped through the same `authorized_resource_scope` every cross-resource read goes through, so a run in another tenant can never surface. If a resource_class is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while trying to build a link to a route that doesn't exist.
|
|
266
|
+
- **A running banner.** Any OTHER resource's index page lists runs currently in progress against it, above the collection, so a user who dispatched a bulk action and navigated away can find it again. It also lists the viewer's own runs that finished within `config.async_interactions.recent_runs_window` (default 10 minutes, newest first, at most 5), with their outcome and a "View run" link. A dispatch sends the user back to this index, and a short run, or one its `pre_check` rejected, has often finished before the page renders. Scoped through the same `authorized_resource_scope` every cross-resource read goes through, so a run in another tenant can never surface. If a resource_class is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while trying to build a link to a route that doesn't exist.
|
|
211
267
|
- **Tenant scoping.** A run's `associated_with` scope filters on the tenant it was dispatched in (recorded on the row), not on walking the object graph, since the two polymorphic tenant columns make the generic scope unusable.
|
|
268
|
+
- **Runs named by their interaction.** A run declared with an `async` block is titled by its interaction's label ("Archive all (async) #19"), from `presents label:` or, without one, the class name minus its `Interaction` suffix. A standalone run class passed to `async` can be shared by several interactions, so it is named after its own class. Only `presents label:` reaches the run: a `label:` on the `action` declaration, or a `plutonium.actions.<model>.<action>` translation, renames the button but not the runs it starts, so put the label on the interaction when both should match.
|
|
212
269
|
- **A humanized target label.** `run.target_label` reads the target class through `model_name.human` ("Post", not "Blogging::Post"), falling back to the raw string if that class has since been renamed or removed.
|
|
213
270
|
|
|
214
271
|
The progress page, mid-run:
|
|
215
272
|
|
|
216
|
-

|
|
217
274
|
|
|
218
275
|
And the banner it leaves on the target resource's index while it is still going:
|
|
219
276
|
|
|
220
|
-

|
|
221
278
|
|
|
222
279
|
## Stalled runs and ReapJob
|
|
223
280
|
|
|
@@ -255,7 +312,7 @@ What bounds that is `lock_version`. Both the reaper's resume and the executor's
|
|
|
255
312
|
|
|
256
313
|
`stall_after` is a **silence** threshold, not a runtime limit. Every write the executor makes refreshes the clock, so a targeted run with quick targets heartbeats once per target for free. Two shapes get nothing, and both are exactly the "long-running task" case:
|
|
257
314
|
|
|
258
|
-
- **Opaque work
|
|
315
|
+
- **Opaque work, and any `pre_check`.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count. A `perform` that outlives `stall_after` is reaped mid-flight and, having no `handled_target_ids` to resume from, re-runs **from scratch**.
|
|
259
316
|
- **A single `perform_on`** that outlives `stall_after` on its own.
|
|
260
317
|
|
|
261
318
|
Call `heartbeat!` from inside such work:
|
|
@@ -36,19 +36,43 @@ another environment is left untouched.
|
|
|
36
36
|
|
|
37
37
|
## `pu:lite:maintenance`
|
|
38
38
|
|
|
39
|
-
Installs `app/jobs/sqlite_maintenance_job.rb` and
|
|
40
|
-
schedules it in `config/recurring.yml
|
|
39
|
+
Installs `app/jobs/sqlite_maintenance_job.rb` and, when `solid_queue` is present,
|
|
40
|
+
schedules it in `config/recurring.yml` along with SolidQueue's finished-job cleanup.
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
43
|
rails g pu:lite:maintenance
|
|
44
|
-
# custom
|
|
45
|
-
rails g pu:lite:maintenance --schedule="every day at 4am"
|
|
44
|
+
# custom schedules:
|
|
45
|
+
rails g pu:lite:maintenance --schedule="every day at 4am" \
|
|
46
|
+
--clear-finished-schedule="every 30 minutes"
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
The job runs `PRAGMA optimize` on every configured SQLite database and `VACUUM`
|
|
49
50
|
only on databases without live 24/7 writers (`primary`, `errors`, `rails_pulse`
|
|
50
51
|
by default; edit `VACUUM_DBS` in the generated job to suit your app).
|
|
51
52
|
|
|
53
|
+
Even on those databases, `VACUUM` is skipped unless at least 10% of the file's
|
|
54
|
+
pages are free (`VACUUM_MIN_FREE_RATIO`). The rewrite holds its exclusive lock for
|
|
55
|
+
as long as it takes, which on a large `primary` can be long enough for requests to
|
|
56
|
+
time out, and WAL reuses free pages anyway. In practice it runs after a bulk delete
|
|
57
|
+
and is skipped (with a log line) the rest of the time.
|
|
58
|
+
|
|
59
|
+
### Clearing finished SolidQueue jobs
|
|
60
|
+
|
|
61
|
+
SolidQueue keeps finished jobs by default (`preserve_finished_jobs`) and never
|
|
62
|
+
deletes them on its own, so the queue database grows without bound. The generator
|
|
63
|
+
adds this task:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
clear_solid_queue_finished_jobs:
|
|
67
|
+
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
|
68
|
+
schedule: "every hour at minute 12"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
It deletes jobs finished longer ago than `clear_finished_jobs_after` (one day by
|
|
72
|
+
default) in batches, sleeping between them so the scheduler and workers keep their
|
|
73
|
+
turn at the write lock. New Rails 8 apps already ship this task; the generator
|
|
74
|
+
detects any existing `clear_finished_in_batches` entry and skips.
|
|
75
|
+
|
|
52
76
|
**Why VACUUM only some databases?** SolidQueue, Solid Cache and Solid Cable write
|
|
53
77
|
to their databases constantly. `VACUUM` takes a global *exclusive* lock for its
|
|
54
78
|
whole duration, which stalls and errors those processes (e.g. SolidQueue process
|
|
@@ -61,5 +85,5 @@ Databases listed in the job that don't exist in `config/database.yml` are skippe
|
|
|
61
85
|
at runtime, so the same job is safe regardless of which `pu:lite:*` generators you
|
|
62
86
|
have run.
|
|
63
87
|
|
|
64
|
-
If `solid_queue` is not installed, the job file is still created but
|
|
65
|
-
add a `sqlite_maintenance` entry to whatever scheduler you use.
|
|
88
|
+
If `solid_queue` is not installed, the job file is still created but nothing is
|
|
89
|
+
scheduled: add a `sqlite_maintenance` entry to whatever scheduler you use.
|
data/docs/reference/i18n.md
CHANGED
|
@@ -145,7 +145,7 @@ end
|
|
|
145
145
|
|
|
146
146
|
## Derived labels
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
Scopes, filters, kanban columns and wizard steps default to a humanized version of their key. An action backed by an interaction defaults to the interaction's class name without its `Interaction` suffix (`ArchivePostsInteraction` reads "Archive Posts"). Each has a convention key that takes precedence over that fallback, and a `label:` option that takes precedence over both. For an action backed by an interaction, the interaction's explicit `presents label:` also counts as declared and wins over the convention; only the class-name default yields to it.
|
|
149
149
|
|
|
150
150
|
```yaml
|
|
151
151
|
en:
|