Class: Axn::Core::ActionAttempt
- Inherits:
-
Object
- Object
- Axn::Core::ActionAttempt
- Defined in:
- lib/axn/core/executor.rb
Overview
Executor encapsulates the full execution pipeline for an action. It owns all the wrapper logic that was previously spread across instance methods, reducing the number of methods injected into user classes.
The execution pipeline has two zones separated by the exception boundary:
Outside zone (result settled, must not raise):
- nesting_tracking: manages the axn stack
- tracing: reads result.outcome, result.elapsed_time, result.exception
- logging: reads result.ok?, result.outcome, result.elapsed_time
Boundary:
- exception_handling: catches exceptions, sets result state, dispatches callbacks
Inside zone (can raise fail!/done!):
-
timing: sets elapsed_time via ensure
-
contract: validates inputs/outputs, applies defaults/preprocessing
-
hooks: user before/after/around hooks What happened to ONE attempt at running the action, so the observers wrapped around it cannot misreport it. Tracing calls an app-supplied object that WRAPS the action (
in_spantakes the work as a block), so the action's fate and the observer's are entangled by construction — this is what keeps them separable:started? the exactly-once guarantee: the action began, so nothing may run it again error the exception the wrapped stack raised, kept so an observer that swallows, replaces, or re-raises it cannot decide the call's outcome abandoned? a
throwunwound the stack BEFORE the result settled. Unlike an exception this cannot be re-thrown once an observer has caught it — the tag and value are gone — so it exists only to refuse to report a success. A throw that unwinds AFTER settlement is a side channel failing on its way out, not the action being abandoned, and leaves the settled result alone. settled? whether the action's RESULT finalized, asked of the action rather than inferred from how this block ended. The two diverge exactly where it matters: a completion- side unwind leaves a settled result behind, and it is settlement — not a normal return — that decides both abandonment above and whether a span has an outcome worth describing.
Instance Attribute Summary collapse
-
#error ⇒ Object
readonly
Returns the value of attribute error.
Instance Method Summary collapse
- #abandoned? ⇒ Boolean
-
#claim ⇒ Object
Takes the one permitted attempt, returning false if it is already taken.
-
#claim_notification ⇒ Object
The same one-winner rule for emitting
axn.call, tracked separately from the action's claim because the two are not the same event: a notification can be attempted and fail before the action begins, and must not then be retried — a subscriber may already have committed a side effect. - #claimed? ⇒ Boolean
-
#close! ⇒ Object
Ends the attempt's lifetime at the tracing boundary, so nothing may act on it afterward.
- #execute ⇒ Object
-
#initialize(settled:) ⇒ ActionAttempt
constructor
settledanswers whether the action's result has FINALIZED — the samefinalized?signallog_aftergates on, and true on every settling path (success,fail!,done!, a recorded exception). -
#observe ⇒ Object
Records how a block completed WITHOUT claiming or starting anything — for work wrapped around the action (the notification) whose abnormal exit must be visible for the same reasons.
-
#originating_context? ⇒ Boolean
Whether the action BODY began.
- #require_originating_context! ⇒ Object
-
#settled? ⇒ Boolean
Deliberately swallows everything: this is consulted from an
ensurewhile athrowis unwinding, so a raise here would replace the in-flight unwind with an error from the very bookkeeping meant to preserve it. - #started? ⇒ Boolean
Constructor Details
#initialize(settled:) ⇒ ActionAttempt
settled answers whether the action's result has FINALIZED — the same finalized? signal
log_after gates on, and true on every settling path (success, fail!, done!, a recorded
exception). Injected as a predicate rather than read from the action here so the attempt stays
a record of what happened to one call, with no opinion about how a result is shaped.
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/axn/core/executor.rb', line 52 def initialize(settled:) @settled = settled # The thread AND fiber the call arrived on — the two axes ActiveSupport::IsolatedExecutionState # scopes by, and axn's per-execution state (nesting stack, exception classification, carried # presentation) was established out here before tracing began. A body running elsewhere sees an # empty axn stack: wrong log prefixes, wrong nested-call classification, wrong breadcrumbs. # # The fiber matters for a second reason the thread does not: a block invoked in a fresh Fiber # can SUSPEND mid-action and hand control back, so `in_span` returns with the action started # and unfinished, and the caller is given a result still being written. @thread = Thread.current @fiber = Fiber.current @lock = Thread::Mutex.new @claimed = false @notification_claimed = false @started = false @completed = false @error = nil @abandoned = false @closed = false end |
Instance Attribute Details
#error ⇒ Object (readonly)
Returns the value of attribute error.
46 47 48 |
# File 'lib/axn/core/executor.rb', line 46 def error @error end |
Instance Method Details
#abandoned? ⇒ Boolean
111 |
# File 'lib/axn/core/executor.rb', line 111 def abandoned? = @abandoned |
#claim ⇒ Object
Takes the one permitted attempt, returning false if it is already taken. Checking and taking are ONE operation: a tracer may invoke the block it was handed from more than one thread, and a separate test could pass in several of them before any set the flag — which would run the business action more than once.
117 118 119 120 121 122 123 124 |
# File 'lib/axn/core/executor.rb', line 117 def claim @lock.synchronize do return false if @closed || @claimed @claimed = true true end end |
#claim_notification ⇒ Object
The same one-winner rule for emitting axn.call, tracked separately from the action's claim
because the two are not the same event: a notification can be attempted and fail before the
action begins, and must not then be retried — a subscriber may already have committed a side
effect. Claiming rather than testing a flag matters for the same reason it does above: a tracer
yielding from two threads could otherwise emit the event twice.
131 132 133 134 135 136 137 138 |
# File 'lib/axn/core/executor.rb', line 131 def claim_notification @lock.synchronize do return false if @closed || @notification_claimed @notification_claimed = true true end end |
#claimed? ⇒ Boolean
108 |
# File 'lib/axn/core/executor.rb', line 108 def claimed? = @claimed |
#close! ⇒ Object
Ends the attempt's lifetime at the tracing boundary, so nothing may act on it afterward. Context identity alone cannot express this: a tracer that CAPTURES the block it was handed, cancels out before invoking it, and calls it later on the same thread and fiber presents an originating context that is genuinely the caller's — just no longer a live one. The cancellation path deliberately leaves both claims unused (the fallback must not run the action after the caller has given up), so without this the deferred callback would find them available and turn abandoned work into committed side effects.
81 82 83 |
# File 'lib/axn/core/executor.rb', line 81 def close! @lock.synchronize { @closed = true } end |
#execute ⇒ Object
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/axn/core/executor.rb', line 173 def execute @started = true begin value = yield @completed = true value # Every escaping class, not axn's swallow allowlist: this RECORDS and always re-raises, never # absorbs, so widening it does not widen what axn swallows. A tracer wrapping its yield in # `rescue Exception` can eat an `Interrupt` from the wrapped stack as easily as a # `StandardError`, and a cancellation turning into a reported success is the worst outcome # available here. rescue Exception => e # rubocop:disable Lint/RescueException @error = e raise ensure # `settled?` is what separates the two unwinds that reach here without an exception. A # `throw` from the action's own body abandoned the call and must never be reported as a # success. A `throw` from the completion side — `with_logging`'s `log_after`, `with_timing`'s # ensure, both of which run INSIDE this block but after `with_exception_handling` settled the # result — is a side channel failing on its way out of a call that already finished. Marking # that abandoned would let a logger take down a completed action, which is the same failure # `observe` refuses via `@started` one layer out. @abandoned = true unless @completed || @error || settled? end end |
#observe ⇒ Object
Records how a block completed WITHOUT claiming or starting anything — for work wrapped around the action (the notification) whose abnormal exit must be visible for the same reasons.
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/axn/core/executor.rb', line 142 def observe completed = false raised = false begin value = yield completed = true value rescue Exception => e # rubocop:disable Lint/RescueException raised = true # An observer raising an ORDINARY error is an observer failure: logged and swallowed by the # guard, which then lets the fallback run the action. Deliberately not recorded, or a # subscriber's own bug would propagate as the action's outcome. # # A class axn never swallows is the opposite — a cancellation passing through, which a # tracer that absorbs everything would otherwise erase, leaving the fallback free to run the # action after its caller had already given up. # Ordinarily an observer raising an ordinary error is an observer FAILURE — logged, swallowed, # and the fallback still runs the action. Under dev-loud that policy inverts: best_effort # re-raises tracing failures on purpose, so a tracer that swallows must not be able to quietly # undo it. Recorded in that mode so the guard re-raises it. @error ||= e unless Axn::Extensions.swallowable?(e) && !Axn::Extensions.raises_in_dev? raise ensure # `@started` matters here and not in `execute`: an unwind through the notification AFTER the # action has run is a side channel failing on its way out, not the action being abandoned. # Marking it abandoned would let a subscriber throwing from `finish` replace a settled result # with a synthetic error. @abandoned = true unless completed || raised || @error || @started end end |
#originating_context? ⇒ Boolean
Whether the action BODY began. Deliberately not the same as having been claimed: an observer
can claim the attempt and then fail before reaching the action (a notification subscriber
raising from start), and the untraced fallback has to be able to tell those apart.
Raises unless called on the thread the attempt was created on. Checked BEFORE claiming, so a
tracer that hands the block to a worker cannot start the action there — the untraced fallback
then runs it on the right thread, with the execution state it belongs to, rather than the call
being lost.
True when the caller is on the thread and fiber the attempt was created on — so it is a context
that could legitimately have run the action, rather than one whose block was refused.
Through Internal::Identity like every other identity check on this path, rather than dispatching
equal? to a Thread or Fiber the app may have subclassed. Same reasoning as swallowable?: the
object's opinion of its own identity was never the question, and this answer decides whether a
context may claim the notification and run the action — so it has to come from the objects
themselves, not from a method one of them defines.
99 |
# File 'lib/axn/core/executor.rb', line 99 def originating_context? = Internal::Identity.same?(Thread.current, @thread) && Internal::Identity.same?(Fiber.current, @fiber) |
#require_originating_context! ⇒ Object
101 102 103 104 105 106 |
# File 'lib/axn/core/executor.rb', line 101 def require_originating_context! return if originating_context? raise "axn.call tracing invoked the action on a different thread or fiber than the caller's: a " \ "tracer must invoke the block it is given synchronously, on the calling thread and fiber." end |
#settled? ⇒ Boolean
Deliberately swallows everything: this is consulted from an ensure while a throw is
unwinding, so a raise here would replace the in-flight unwind with an error from the very
bookkeeping meant to preserve it. An unanswerable predicate means "not known to have settled",
which is the conservative reading — it keeps the pre-existing abandonment behavior.
203 204 205 206 207 |
# File 'lib/axn/core/executor.rb', line 203 def settled? @settled.call rescue Exception # rubocop:disable Lint/RescueException false end |
#started? ⇒ Boolean
109 |
# File 'lib/axn/core/executor.rb', line 109 def started? = @started |