fix(otel): balance context attach and detach across plugin lifecycles - #647
fix(otel): balance context attach and detach across plugin lifecycles#647wangyb-A wants to merge 1 commit into
Conversation
This comment has been minimized.
This comment has been minimized.
ceb4551 to
c79cfda
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
| # Stamp ownership into the context itself so it travels with any | ||
| # propagation of it (see _OWNER_KEY). | ||
| context = otel_context.set_value( | ||
| _OWNER_KEY, (*_owner_ids(context), owner_id), context=context |
There was a problem hiding this comment.
Codex AI review
[P2] Preserve existing scope owners when the other bundled plugin attaches. The second plugin rebases its context onto _extracted_context, so _owner_ids(context) contains only that plugin. With both entry points enabled, the first plugin no longer trusts the active attempt span and the installed log filter falls back to its Invocation span, losing operation-level correlation. Merge the current owner IDs into the produced context after cleanup, and test logging with both actual plugins enabled.
| A scope abandoned by a *different* operation on this thread cannot be | ||
| detected here. Physical nesting is not derivable from the hook payloads: | ||
| ``parent_id`` is checkpoint hierarchy, and a FLAT map/parallel branch | ||
| deliberately reports its inner operations' parent as the grandparent (see | ||
| ``DurableContext.is_virtual``), so a live branch scope would be | ||
| indistinguishable from an abandoned sibling. Closing that gap needs the SDK | ||
| to report the end of a suspended user function, which it does not do today. |
There was a problem hiding this comment.
Codex AI review
[P2] This unhandled case is reachable when a map/parallel branch suspends, another branch frees a concurrency slot, and the executor reuses the suspended branch's thread for a different branch. The different key and unchanged epoch bypass both cleanup checks, so the new scope inherits the suspended branch's context and restores it afterward, misattributing subsequent logs and instrumentation. Add a suspension lifecycle hook or finally path in core so plugins can detach on the same worker without reporting success, plus a worker-reuse test using different branch IDs.
Codex AI reviewTwo P2 observability regressions remain in the new scope bookkeeping, affecting dual-plugin use and reused concurrent workers. Tests were not executed under the review constraints. Reviewed commit |
Claude AI reviewReviewed the OTel context attach/detach balancing change against the changed source and surrounding base-revision code in both plugins, No actionable defects found. The fix is correct and well-tested. Confirmed:
Residual risk (non-blocking, already documented in the PR):
Reviewed commit |
Both plugins called opentelemetry.context.attach() without keeping the returned token, and "restored" the enclosing span by attaching another context rather than detaching. Every operation pushed two context layers and popped none, leaving an ended span current after its scope had finished. The worst consequence: the invocation-start attach runs on the Lambda handler thread, which is reused across warm invocations, so the next execution's context extractor and GLOBAL-mode ambient-parent lookup adopted the previous execution's ended Workflow span. A valid parent overrides the deterministic ID generator's trace ID, so two unrelated durable executions merged into a single trace. Each attach now keeps its token, keyed the same way as the span registry, and the hook that pairs with it pops the scope: - on_invocation_start attaches; on_invocation_end detaches. - on_user_function_start attaches; on_user_function_end detaches, replacing the re-attach that stood in for restoring the enclosing context. - Anything still held when the invocation ends is swept, since the SDK re-raises SuspendExecution without calling on_user_function_end. A scope is only detached while it is still the current one. That mirrors OpenTelemetry Java's ScopeImpl.close(), which ignores a close that does not represent the current context, and it matters more in Python: ContextVar.reset writes back its captured value unconditionally, so an out-of-order or wrong-thread detach would revive a stale context instead of failing safe. A skipped detach keeps its entry so the owning thread can still undo it. Tests no longer reset the OTel context to isolate themselves; an autouse fixture asserts instead that every test leaves the context exactly as it found it, and the tests that drove hooks without completing the lifecycle now complete it. Adds coverage for warm invocation reuse keeping two executions in separate traces, a suspended operation's scope being swept, sequential steps not accumulating layers, exact restore on success and failure, nested child contexts, worker-thread confinement, and the identity guard skipping both out-of-order and cross-thread detaches.
8ace0f5 to
4e59e90
Compare
Fixes #643
Problem
Both OTel plugins called
opentelemetry.context.attach()without keeping the returned token, and "restored" the enclosing span by attaching another context rather than detaching. Every operation therefore pushed two context layers and popped none, leaving an ended span current after its scope had finished.The worst consequence is not in the issue. The invocation-start attach (
execution_plugin.py:219) ran on the Lambda handler thread, which is reused across warm invocations, so the next execution's context extractor (context_extractors.py:27) and GLOBAL-mode ambient-parent lookup (execution_plugin.py:252) adopted the previous execution's ended Workflow span. A valid parent overrides the deterministic ID generator's trace ID, so two unrelated durable executions merged into a single trace. Measured with a runtime probe before the fix:and after:
Two runtime properties shaped the fix. First, the hooks run on several threads — the invocation hooks on the Lambda handler thread, the user-function hooks on the
dex-handlerworker that runs user code, and on a branch worker permap/parallelbranch — andContextVar.reset()only accepts a token created in the samecontextvars.Context. Second, unlike Java'sScopeImpl.close(), which ignores a close that does not represent the current context,ContextVar.reset()writes back its captured value unconditionally, so detaching out of order revives a stale context instead of failing safe.Changes
context_scope.py— a module-level, thread-confined LIFO stack of attach tokens.exit_scopeunwinds downwards so the underlyingContextVaris always reset in order. The stack is module level rather than per plugin instance because both plugins ship as separate entry points and can be enabled together: hooks dispatch in registration order, so the second plugin's scope must come off while the first plugin's end hook runs. An epoch check discards scopes a suspended operation left behind, since the SDK re-raisesSuspendExecutionwithout callingon_user_function_end(state.py:1171).ThreadPoolExecutordoes not copy contextvars, so that attach never reached the code it was meant to parent — it only leaked. The Workflow and Invocation spans are used as explicit parents instead, matching the Java plugins, which never make either span current.Behaviour change
Ambient auto-instrumented spans emitted outside any operation — for example directly in the handler between two steps — are no longer parented to the Invocation span. In an ADOT deployment they attach to the ambient Lambda invocation span instead, which the previous invocation-start attach was shadowing. This matches
ExecutionOtelPlugin/InvocationOtelPluginin the Java SDK, which cover the same window with MDC rather than an attached span. Calls inside a step or child context are unaffected.Log correlation is unchanged: the logging filter resolves through the plugin's span registry, so records emitted between operations still carry the invocation's
traceIdandspanId. One visible detail — handler-thread records now carry the Invocation span'sspanIdinstead of the Workflow span's (sametraceId), since the registry prefers the Invocation span; there is a test pinning this.The durable span hierarchy itself is untouched: parents, links, and deterministic IDs are all chosen explicitly in
_start_span, never taken from the ambient context.Acceptance criteria
context.attach()token owned by the plugin has a correspondingcontext.detach()— with one documented exception below.Documented exception to the first criterion: scopes attached on a worker thread that suspends cannot be detached from the handler thread where invocation end runs, because a token is only resettable in the context that created it. Those threads are created per invocation and their
ContextVardies with them, and the epoch check discards any leftover if a thread is ever reused. The Java plugins have the same gap — their invocation-end sweep iterates an unordered map from the handler thread, so those closes hitScopeImpl's guard and are ignored.Testing
129 tests pass in the otel package, 3223 across the monorepo;
hatch fmt --checkandhatch run types:checkclean.New coverage:
tests/test_context_scope.py(13 tests — LIFO nesting, unwind-above-target, unknown-key no-op, epoch discard, thread confinement, suspension, worker-thread hooks, both plugins on one thread), a warm-reuse test asserting two executions land in separate traces, a pre/post invocation context-restore test, and a log-filter test pinning the handler-threadspanId.Both halves were mutation-tested. Restoring the invocation-start attach fails 11 tests including the warm-reuse and context-restore ones; skipping the detach in
on_user_function_endfails 10 across the balance fixtures and the restore tests.Four tests that asserted "the invocation span is current again after a step" were rewritten to assert exact context restore instead, since that behaviour came from the unbalanced re-attach. Three nested-context tests kept their assertions unchanged and needed only their lifecycle completed — they started a child context and never ended it, which the old reset fixture silently swallowed. Notably
test_get_current_span_context_returns_invocation_span_between_stepspasses untouched, which is the evidence that log correlation survived.Follow-ups (not in this PR)
wrap_user_functionre-raisesSuspendExecutionwithout callingon_user_function_end(state.py:1171), so a suspended operation's hooks are structurally unpaired for every plugin, not just these two. Worth a core-side fix or an explicit suspension hook.AGENTS.mdthat belongs upstream inaws-durable-execution-conformance-testsfirst.