plutonium 0.62.2 → 0.63.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 (255) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -0,0 +1,422 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module Async
6
+ # Performs a run: resolves its targets, calls the subclass's work, keeps
7
+ # progress current, and applies the declared failure policy.
8
+ #
9
+ # Separate from {Plutonium::Interaction::Async::Run} so the record stays a record,
10
+ # and separate from {Job} so the whole thing can be driven synchronously in
11
+ # a test or a console.
12
+ #
13
+ # == Authorization is re-derived here, per target, at the last moment
14
+ #
15
+ # {Context#targets} answers "may the initiator act on these?" once, up
16
+ # front. That answer makes a good operator report but it is only true as of
17
+ # the moment it was computed, and a bulk run over thousands of records acts
18
+ # long afterwards. So the answer is re-derived immediately before each
19
+ # +perform_on+, because BOTH of its inputs go stale:
20
+ #
21
+ # * the SUBJECTS (initiator, tenant) are cached on the context. They are
22
+ # re-read on a clock — see {SUBJECT_REFRESH_INTERVAL}.
23
+ # * the RECORD is a snapshot from the resolution query, and predicates read
24
+ # record state (Blogging::PostPolicy#archive? is literally
25
+ # +record.published?+). Each target is re-read through the policy scope
26
+ # right before its check, which also catches a record that left the
27
+ # tenant mid-run.
28
+ #
29
+ # A target that fails the re-check is RECORDED as a failure, never silently
30
+ # skipped: "you may no longer act on 3 of these" is exactly what tells an
31
+ # operator the run under-applied.
32
+ #
33
+ # == Resuming after an interruption
34
+ #
35
+ # #call only ever STARTS from "pending" (see #claim!) — a run already
36
+ # "running" is left alone, since two concurrent executors on the same
37
+ # row would race. A run interrupted mid-batch (crash, dropped job) can
38
+ # still be resumed safely: reset it to "pending" (see Async::ReapJob)
39
+ # and re-enqueue. Context#targets resolves only Run#unhandled_target_ids,
40
+ # so a target already dispositioned before the interruption is not
41
+ # redone.
42
+ class Executor
43
+ # How long a resolved (initiator, tenant) pair is trusted before
44
+ # {Context#refresh_subjects!} re-reads it.
45
+ #
46
+ # Wall-clock rather than per-record because that is the shape of the risk
47
+ # being managed: revocation urgency is measured in seconds, not in
48
+ # records. Refreshing per record costs two queries per target — 20,000
49
+ # extra queries on a 10,000-target run — to close a window this closes
50
+ # for a handful.
51
+ SUBJECT_REFRESH_INTERVAL = 5.seconds
52
+
53
+ # Raised in place of calling +perform_on+ when the just-in-time re-check
54
+ # refuses a target. A StandardError so it travels the same path as a
55
+ # failure raised by the author's own code: a revoked permission is a
56
+ # target failure, and the run's failure policy decides what that means.
57
+ #
58
+ # Deliberately never rescued BY TYPE — the blanket per-target rescue in
59
+ # {#perform_one} catches it, which is the point. It exists to name the
60
+ # condition at the raise site and in a log entry. Adding a typed rescue
61
+ # for it would take it out of the failure policy's hands.
62
+ class TargetRefusedError < StandardError; end
63
+
64
+ # Raised out of a +:transactional+ batch so the run-level entry written
65
+ # after the rollback still names the target that caused it — the
66
+ # per-target entry that would have named it went back with the
67
+ # transaction.
68
+ #
69
+ # Also never rescued by type: {#perform_all_or_nothing} catches
70
+ # everything so it can reload after the rollback, and {#call} records it.
71
+ class BatchAbortedError < StandardError; end
72
+
73
+ attr_reader :run
74
+
75
+ def initialize(run)
76
+ @run = run
77
+ end
78
+
79
+ # @return [void]
80
+ def call
81
+ return unless claim!
82
+
83
+ @context = build_context
84
+
85
+ run.targeted? ? perform_targets : perform_opaque
86
+ rescue StandardError, NotImplementedError => e
87
+ # Another executor owns this run now: ReapJob judged it stalled, reset
88
+ # it to pending, and a second job claimed it — bumping lock_version out
89
+ # from under us (see Async::ReapJob, #claim! and #superseded?).
90
+ #
91
+ # Returning without touching the row is the whole point. Every write
92
+ # this executor still holds is stale by definition, so recording a
93
+ # failure here would either raise again or overwrite the live
94
+ # executor's progress with our older copy. The run is not failed; it is
95
+ # simply no longer ours.
96
+ #
97
+ # Checked ahead of the failure path rather than in a rescue clause of
98
+ # its own, because the two are told apart by the errored RECORD, not by
99
+ # the exception class — see #superseded?.
100
+ if superseded?(e)
101
+ Rails.logger.warn {
102
+ "plutonium: interaction run #{run.id} was reclaimed by another executor; abandoning this pass"
103
+ }
104
+ return
105
+ end
106
+
107
+ # NotImplementedError is NOT a StandardError, and two things here raise
108
+ # it: a run subclass that implements no work at all, and a policy
109
+ # predicate that has been renamed since enqueue (Policy#send_with_report).
110
+ # Both must land in the run's log rather than escaping.
111
+ #
112
+ # Swallowed rather than re-raised because ActiveJob would retry, and a
113
+ # retry re-applies every target the run already committed. The row is
114
+ # the report, and it now reads failed, with the reason.
115
+ Rails.logger.warn { "plutonium: interaction run #{run.id} (#{run.class}) failed: #{e.message}" }
116
+ record_failure(e)
117
+ end
118
+
119
+ private
120
+
121
+ attr_reader :context
122
+
123
+ # Records the run's failure, and refuses to let a SECOND failure escape.
124
+ #
125
+ # #call swallows the original deliberately — a re-raise would have
126
+ # ActiveJob retry, re-applying every target already committed. That
127
+ # promise is only kept if the write recording it cannot raise either.
128
+ #
129
+ # An escaping fail! is worse than the failure it was reporting: the row
130
+ # is already "running" from #claim!, so every retry's claim! matches zero
131
+ # rows and no-ops, silently burning the queue's retry budget while the
132
+ # run sits wedged at "running" until ReapJob eventually resets it.
133
+ def record_failure(error)
134
+ run.fail!(error.message)
135
+ rescue => e
136
+ Rails.logger.error {
137
+ "plutonium: interaction run #{run.id} could not record its failure " \
138
+ "(#{e.class}: #{e.message}); the failure it was recording: #{error.message}"
139
+ }
140
+ end
141
+
142
+ # Atomically claims a PENDING run so two concurrent deliveries (retry
143
+ # after a crash, duplicate enqueue) can't both process it. A run
144
+ # already "running" is left alone rather than replayed — that would
145
+ # re-invoke perform_on on targets already applied.
146
+ #
147
+ # The claim also BUMPS lock_version, which is what turns ReapJob's
148
+ # time-based resume into a real fence: without it an executor superseded
149
+ # mid-batch would keep writing happily until it happened to collide with
150
+ # the new one — losing whichever progress write landed second. Bumping
151
+ # here means the superseded executor's very next save! finds its
152
+ # lock_version stale and raises, and #call treats that as "no longer
153
+ # mine". update_all never CHECKS the column, so nothing here can fail on
154
+ # it; the claim is arbitrated by the state predicate alone.
155
+ #
156
+ # Spelled out as SQL rather than passed as a hash because the increment
157
+ # has to be deliberate and visible. Rails adds one of its own to the HASH
158
+ # form of update_all whenever the model locks (see
159
+ # ActiveRecord::Relation#update_all) — a hash here would still work, but
160
+ # by an invisible rule that the string form does not follow, and
161
+ # Run#heartbeat! depends on knowing which form does what.
162
+ #
163
+ # Reloaded rather than assign_attributes'd: the row now holds a
164
+ # lock_version this instance never saw, and every later write depends on
165
+ # carrying the current one. One query per RUN (not per target).
166
+ #
167
+ # @return [Boolean]
168
+ def claim!
169
+ now = Time.current
170
+ claimed = run.class.where(id: run.id, state: "pending")
171
+ .update_all([
172
+ "state = ?, started_at = ?, last_activity_at = ?, lock_version = lock_version + 1",
173
+ "running", now, now
174
+ ]) == 1
175
+
176
+ unless claimed
177
+ Rails.logger.warn { "plutonium: interaction run #{run.id} is #{run.state}; refusing to (re)start" }
178
+ return false
179
+ end
180
+
181
+ run.reload
182
+ true
183
+ end
184
+
185
+ def build_context = Context.new(run)
186
+
187
+ # send, not a plain call: Run#targeted? detects a non-public perform_on,
188
+ # so the two shapes of work must be INVOKED the same way they are
189
+ # detected. `private def perform` is a natural idiom for a method only
190
+ # the framework is meant to call, and it must not change what the
191
+ # executor does with it.
192
+ def perform_opaque
193
+ run.send(:perform)
194
+ run.finish!
195
+ end
196
+
197
+ def perform_targets
198
+ resolved = context.targets
199
+ record_unresolved(resolved)
200
+
201
+ return refuse_partial_batch(resolved) if unresolved?(resolved) && !continue?
202
+
203
+ if transactional?
204
+ perform_all_or_nothing(resolved.records)
205
+ run.finish!
206
+ elsif (remaining = perform_each(resolved.records))
207
+ # The loop stopped, so the remaining targets were never attempted.
208
+ # A run that did not do its job must not read as completed.
209
+ run.fail!("stopped at the first target failure (#{run.failure_policy} policy); " \
210
+ "#{remaining} target(s) were not attempted")
211
+ else
212
+ # Partial failure under :continue is COMPLETED, not failed. The author
213
+ # declared partial application acceptable, the executor ran to the
214
+ # end, and errors_log plus progress_done carry the shortfall. Keeping
215
+ # "failed" for runs that stopped early leaves the word meaning one
216
+ # thing, which is what a retry — and the index banner — need.
217
+ run.finish!
218
+ end
219
+ end
220
+
221
+ # @return [Integer, nil] how many records were never attempted, if the
222
+ # failure policy stopped the loop early — nil if it ran to the end
223
+ def perform_each(records)
224
+ records.each_with_index do |record, index|
225
+ return records.size - index - 1 if perform_one(record) == :halt
226
+ end
227
+ nil
228
+ end
229
+
230
+ def perform_all_or_nothing(records)
231
+ # Model.transaction already wraps the block in with_connection (see
232
+ # ActiveRecord::Transactions::ClassMethods#transaction), so the batch
233
+ # holds one leased connection for its whole duration — which is both
234
+ # what a transaction requires and what fiber-safety asks for. Reaching
235
+ # for the connection ourselves would only duplicate that lease.
236
+ run.class.transaction do
237
+ records.each { |record| perform_one(record) }
238
+ end
239
+ rescue StandardError, NotImplementedError
240
+ # Everything the block wrote went back with it: progress_done and every
241
+ # per-target errors_log entry appended inside. Re-read so the in-memory
242
+ # run stops carrying values the database no longer has — without this,
243
+ # the fail! that follows would write those rolled-back values straight
244
+ # back out and report work that was undone.
245
+ run.reload
246
+ raise
247
+ end
248
+
249
+ # @return [Symbol, nil] :halt when the failure policy says to stop
250
+ #
251
+ # Two failures deliberately do NOT become target failures, because they
252
+ # are systemic — every remaining target would hit them identically, so
253
+ # recording them per target would write M copies of one diagnosis (the
254
+ # O(M²) errors_log that {#record_unresolved} exists to avoid) and end
255
+ # with the run marked completed:
256
+ #
257
+ # * NotImplementedError ("this code was never written" — a policy
258
+ # predicate renamed since enqueue) is not a StandardError, so the
259
+ # blanket rescue below already lets it through.
260
+ # * Context::UnresolvableError IS one, so it is let through explicitly.
261
+ # * A StaleObjectError raised over THIS RUN's row likewise — and for it
262
+ # "systemic" is an understatement: this executor no longer owns the row
263
+ # at all. One over any OTHER record is an ordinary target failure; see
264
+ # {#superseded?}, which is what tells the two apart.
265
+ def perform_one(record)
266
+ # send: see #perform_opaque.
267
+ run.send(:perform_on, reauthorized(record))
268
+ advance!(record.id)
269
+ nil
270
+ rescue Context::UnresolvableError
271
+ # The initiator or the tenant was deleted while the run was working.
272
+ # See the note above: this is the RUN's failure, not this target's.
273
+ raise
274
+ rescue ActiveRecord::StaleObjectError => e
275
+ # Another executor claimed the run out from under us (see #call).
276
+ # Recording this as a target failure would be doubly wrong: it is not
277
+ # the target's fault, and the write recording it would raise anyway
278
+ # against the same stale lock_version.
279
+ raise if superseded?(e)
280
+
281
+ fail_target(record, e)
282
+ rescue => e
283
+ fail_target(record, e)
284
+ end
285
+
286
+ # Applies the failure policy to one target's failure.
287
+ #
288
+ # Reloads a DIRTY run first. #advance! bumps progress_done and appends to
289
+ # handled_target_ids in memory BEFORE its save!, and a failed save leaves
290
+ # those values on the object without ever having written them. Recording
291
+ # the failure then reads them back as though they had landed, and
292
+ # Run#record_target_failures! adds its own increment on top — a
293
+ # one-target run finishes at progress_done 2 of 1, with the id twice in
294
+ # handled_target_ids.
295
+ #
296
+ # Costs nothing on the ordinary path: a run whose perform_on raised is
297
+ # clean, because the previous advance! saved it.
298
+ #
299
+ # @return [Symbol, nil] :halt when the failure policy says to stop
300
+ def fail_target(record, error)
301
+ run.reload if run.changed?
302
+
303
+ raise BatchAbortedError, "target #{record.id} failed (#{error.message}); no targets were applied" if transactional?
304
+
305
+ run.record_target_failure!(id: record.id, message: error.message)
306
+ halt? ? :halt : nil
307
+ end
308
+
309
+ # Does this error mean the run's own row moved out from under this
310
+ # executor — or is it the author's own optimistic-locking failure, from a
311
+ # record their +perform_on+ happened to touch?
312
+ #
313
+ # The errored RECORD is the whole distinction, and StaleObjectError
314
+ # carries it. Reading only the exception class instead swallows an
315
+ # author's lost update whole: the run is abandoned mid-batch with an
316
+ # empty errors_log, wedged at "running", and — because the target was
317
+ # never added to handled_target_ids — ReapJob resumes it every
318
+ # stall_after forever, re-applying that target's side effects each round.
319
+ # The failure policy the author declared never gets consulted at all.
320
+ #
321
+ # @return [Boolean]
322
+ def superseded?(error)
323
+ return false unless error.is_a?(ActiveRecord::StaleObjectError)
324
+
325
+ error.record.is_a?(Run) && error.record.id == run.id
326
+ end
327
+
328
+ # Re-resolves a target through the policy scope and re-asks the predicate,
329
+ # immediately before the work. Returns the FRESH instance, so the work
330
+ # itself also acts on current state rather than on the snapshot the
331
+ # up-front resolution loaded.
332
+ #
333
+ # One query per target, against a loop that already writes once per
334
+ # target. Going through {Context#authorized_scope} rather than reloading
335
+ # by primary key costs the same query and answers the wider question: a
336
+ # record moved to another tenant since the run was dispatched simply is
337
+ # not there.
338
+ #
339
+ # @raise [TargetRefusedError]
340
+ def reauthorized(record)
341
+ refresh_subjects_if_stale!
342
+
343
+ key = record.class.primary_key
344
+ # record.class, not the run's target_type: an STI row resolves its own
345
+ # policy and therefore its own scope, which is the same rule
346
+ # Context#policy_for follows.
347
+ fresh = context.authorized_scope(record.class.all).find_by(key => record.public_send(key))
348
+ raise TargetRefusedError, missing_message(record.id) if fresh.nil?
349
+ raise TargetRefusedError, unauthorized_message(record.id) unless context.permitted?(fresh)
350
+
351
+ fresh
352
+ end
353
+
354
+ # The clock belongs to the context, which owns the state it describes;
355
+ # the cadence belongs here, because it is this executor's judgement about
356
+ # how much staleness this shape of work can tolerate.
357
+ def refresh_subjects_if_stale!
358
+ return if Time.current - context.subjects_read_at < SUBJECT_REFRESH_INTERVAL
359
+
360
+ context.refresh_subjects!
361
+ end
362
+
363
+ # Targets that were gone or no longer permitted when the run started,
364
+ # recorded in ONE write.
365
+ #
366
+ # record_target_failure! self-persists, so a loop over M ids is M writes,
367
+ # each rewriting the whole errors_log JSON — O(M²) bytes for a bulk run
368
+ # whose targets have mostly disappeared. The plural form appends the
369
+ # batch in a single update!, folding in the progress advance too:
370
+ # unresolved targets still count as dispositioned, so the bar reaches
371
+ # the end.
372
+ def record_unresolved(resolved)
373
+ entries = resolved.missing_ids.map { |id| {id: id, message: missing_message(id)} } +
374
+ resolved.unauthorized_ids.map { |id| {id: id, message: unauthorized_message(id)} }
375
+ return if entries.empty?
376
+
377
+ run.record_target_failures!(entries)
378
+ end
379
+
380
+ def unresolved?(resolved) = resolved.missing_ids.any? || resolved.unauthorized_ids.any?
381
+
382
+ # Note what progress_done means when this fires: {#record_unresolved} has
383
+ # already counted the unresolved targets, so a refused batch finishes
384
+ # with progress_done > 0 having performed NOTHING. The counter tracks
385
+ # targets DISPOSITIONED, not targets attempted — which is the right
386
+ # meaning for a progress bar (it has to reach the end) but is easy to
387
+ # misread as work done. Task 6's page renders it directly: pair it with
388
+ # the run's state, never on its own.
389
+ #
390
+ # :halt and :transactional both promise something a partial batch cannot
391
+ # deliver — stop at the first problem, or apply everything or nothing —
392
+ # and the problem is already known before any work has been done. Doing
393
+ # part of the batch anyway would be the one outcome neither policy allows.
394
+ def refuse_partial_batch(resolved)
395
+ unresolved = resolved.missing_ids.size + resolved.unauthorized_ids.size
396
+ run.fail!("#{unresolved} of #{run.target_ids.size} targets could not be resolved; " \
397
+ "a #{run.failure_policy} run does not apply a partial batch")
398
+ end
399
+
400
+ # Advances progress and records +id+ as dispositioned, in one write —
401
+ # see Run#unhandled_target_ids, which is what lets a resumed run skip
402
+ # it instead of reapplying it.
403
+ def advance!(id)
404
+ run.progress_done += 1
405
+ run.handled_target_ids += [id.to_s]
406
+ run.last_activity_at = Time.current
407
+ run.save!
408
+ end
409
+
410
+ def missing_message(id) = "Target #{id} is no longer available"
411
+
412
+ def unauthorized_message(id) = "Target #{id} is no longer permitted by #{context.policy_action}"
413
+
414
+ def continue? = run.failure_policy == :continue
415
+
416
+ def halt? = run.failure_policy == :halt
417
+
418
+ def transactional? = run.failure_policy == :transactional
419
+ end
420
+ end
421
+ end
422
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module Async
6
+ # The ActiveJob entry point for a run.
7
+ #
8
+ # The only thing that crosses the process boundary is the run's id. Every
9
+ # scrap of context — who started it, which tenant, which targets, which
10
+ # policy — is re-read from the row by {Context}, so nothing is inherited
11
+ # from the dispatching request. That is what makes a run safe to perform
12
+ # minutes or hours later, on another machine.
13
+ class Job < ActiveJob::Base
14
+ # A block, not a value: the queue is read at enqueue time, so a host that
15
+ # configures it in an initializer is not racing this class's load order.
16
+ queue_as { Plutonium.configuration.async_interactions.queue }
17
+
18
+ # A per-run semaphore, when the host's queue offers one. Solid Queue
19
+ # mixes ActiveJob::ConcurrencyControls into ActiveJob::Base whenever it
20
+ # is in the bundle; Plutonium depends on neither, so the declaration is
21
+ # conditional rather than assumed.
22
+ #
23
+ # This is not a second copy of Executor#claim!. claim! can only REFUSE a
24
+ # duplicate delivery, and only once it is already running: the queue has
25
+ # spent a worker slot to find out, and — worse — a resume that the reaper
26
+ # started while the original worker is still mid-batch has by then
27
+ # already re-entered perform_on for one target (see Async::ReapJob, and
28
+ # the lock_version fence that bounds but cannot prevent that). A
29
+ # semaphore removes the race one step earlier: the second delivery waits
30
+ # rather than racing, so on a queue that supports this the double-applied
31
+ # target does not happen at all.
32
+ if respond_to?(:limits_concurrency)
33
+ # Keyed on the RUN, so runs never serialize against each other — only
34
+ # against another delivery of themselves. Solid Queue prefixes the key
35
+ # with the concurrency group (this class's name), so a host job keyed
36
+ # on the same id cannot collide with it.
37
+ #
38
+ # +key+ and +to+ only: they are the two options every Solid Queue
39
+ # release has taken. +on_conflict+ arrived in 1.2, and passing it would
40
+ # turn an older host's boot into an ArgumentError — its default
41
+ # (+:block+) is what a run wants anyway. A discarded delivery would
42
+ # mean a reaper's resume silently dropped when the semaphore it is
43
+ # waiting on is merely stale.
44
+ limits_concurrency to: 1, key: ->(run_id) { run_id }
45
+
46
+ # Read at dispatch time rather than passed to +limits_concurrency+
47
+ # here, for the same reason queue_as takes a block — this class is
48
+ # autoloaded, and a host configures stall_after in an initializer.
49
+ #
50
+ # stall_after is the right duration because it is the same question:
51
+ # "how long may a run be silent before we assume its worker is dead?"
52
+ # The semaphore then expires no later than the point the reaper would
53
+ # resume the run anyway. Solid Queue's 3-minute default would expire
54
+ # mid-batch on any run big enough to be worth dispatching, handing the
55
+ # exclusivity away while the work is still going.
56
+ def self.concurrency_duration = Plutonium.configuration.async_interactions.stall_after
57
+ end
58
+
59
+ def perform(run_id)
60
+ run = Plutonium::Interaction::Async::Run.find_by(id: run_id)
61
+
62
+ # A run deleted between enqueue and perform is not an error — there is
63
+ # simply nothing to do, and raising would only retry until the queue
64
+ # gives up.
65
+ return if run.nil?
66
+
67
+ # Idempotence, and the guard against a retry re-applying committed
68
+ # work: a settled run has already reported its outcome, and performing
69
+ # it again would act on its targets a second time.
70
+ unless run.in_progress?
71
+ Rails.logger.warn { "plutonium: interaction run #{run.id} is #{run.state}; skipping" }
72
+ return
73
+ end
74
+
75
+ Executor.new(run).call
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module Async
6
+ # Resumes runs stuck pending/running long past their last recorded
7
+ # activity — a worker crash mid-batch, or a job the queue silently
8
+ # dropped.
9
+ #
10
+ # Resetting to "pending" and re-enqueuing is safe, not a replay: the
11
+ # executor resumes from Run#unhandled_target_ids, so a target already
12
+ # dispositioned before the interruption is not redone.
13
+ #
14
+ # This is still a heuristic on TIME, not a true lease: a run that is merely
15
+ # slow (not dead) and crosses stall_after gets resumed too. What bounds
16
+ # that is lock_version — see #reap. The resumed row's version no longer
17
+ # matches the live worker's, so the live worker stops at its next write
18
+ # rather than racing. Two things that does NOT do: it cannot interrupt an
19
+ # in-flight perform_on (a target may be applied twice, once by each side),
20
+ # and it cannot roll back what the superseded worker already committed. Set
21
+ # stall_after well above this app's slowest legitimate run.
22
+ #
23
+ # Hosts must schedule this themselves (a periodic job / cron task),
24
+ # same as Wizard::SweepJob.
25
+ class ReapJob < ActiveJob::Base
26
+ # One sweep at a time, when the host's queue offers a semaphore (see
27
+ # Async::Job, which explains the conditional and why only +key+/+to+ are
28
+ # passed). A scheduled sweep that outlives its own interval would
29
+ # otherwise overlap the next tick and rescan the same rows; the atomic
30
+ # UPDATE in #reap keeps that CORRECT, but it is still two workers doing
31
+ # one job's work.
32
+ #
33
+ # A constant key: every sweep is the same sweep, so they queue behind
34
+ # each other globally rather than per row.
35
+ if respond_to?(:limits_concurrency)
36
+ limits_concurrency to: 1, key: ->(*) { "sweep" }
37
+ end
38
+
39
+ def perform(stall_after: Plutonium.configuration.async_interactions.stall_after)
40
+ threshold = stall_after.ago
41
+
42
+ Run.stalled(before: threshold).find_each { |run| reap(run, threshold) }
43
+ end
44
+
45
+ private
46
+
47
+ # The conditional UPDATE re-checks "still stalled" and claims the row
48
+ # in one atomic statement, so a run that progressed (or finished)
49
+ # between the query above and now is left alone.
50
+ #
51
+ # Bumping lock_version is what makes resuming a merely-SLOW run safe
52
+ # rather than merely unlikely. The executor that is still alive holds the
53
+ # old value, so its very next write raises ActiveRecord::StaleObjectError
54
+ # and it abandons the pass (see Async::Executor#call) instead of racing
55
+ # the new one and silently losing whichever progress write landed second.
56
+ # It does not un-apply work already committed — this bounds the damage of
57
+ # a bad stall_after, it does not make one free.
58
+ # last_activity_at is stamped because Run.stalled matches on it: leaving
59
+ # it at its old value means the row this sweep just resumed is STILL
60
+ # stalled, so the next sweep reaps it again, and the one after that —
61
+ # bumping lock_version and re-enqueuing every interval until a worker
62
+ # finally claims it. On a backed-up queue that is an unbounded pile of
63
+ # duplicate deliveries for one run.
64
+ #
65
+ # Resuming is not the run's own activity, but it is activity ON the run,
66
+ # which is what the scope is really asking about: has anything happened
67
+ # here lately. It buys the resumed job a full stall_after to be picked
68
+ # up before this sweep concludes anything again.
69
+ def reap(run, threshold)
70
+ resumed = Run.stalled(before: threshold).where(id: run.id)
71
+ .update_all(["state = ?, last_activity_at = ?, lock_version = lock_version + 1",
72
+ "pending", Time.current]) == 1
73
+ return unless resumed
74
+
75
+ Rails.logger.info { "plutonium: resuming stalled interaction run #{run.id} (#{run.class})" }
76
+ Job.perform_later(run.id)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end