Class: Axn::Core::ActionAttempt

Inherits:
Object
  • Object
show all
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_span takes 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 throw unwound 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

Instance Method Summary collapse

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

#errorObject (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

Returns:

  • (Boolean)


111
# File 'lib/axn/core/executor.rb', line 111

def abandoned? = @abandoned

#claimObject

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_notificationObject

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

Returns:

  • (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

#executeObject



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

#observeObject

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


109
# File 'lib/axn/core/executor.rb', line 109

def started? = @started