plutonium 0.62.2 → 0.64.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/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/.standard.yml +1 -1
- data/CHANGELOG.md +73 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +323 -141
- data/gemfiles/rails_8.0.gemfile.lock +126 -116
- data/gemfiles/rails_8.1.gemfile.lock +127 -117
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +20 -20
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +101 -22
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- 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
|