Skip to content

refactor(sdk): create one plugin instance per invocation - #924

Open
ParidelPooya wants to merge 46 commits into
mainfrom
refactor/per-invocation-plugin-instances
Open

ParidelPooya wants to merge 46 commits into
mainfrom
refactor/per-invocation-plugin-instances

Conversation

@ParidelPooya

@ParidelPooya ParidelPooya commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Handler-scoped plugin instances can mix concurrent Lambda Managed Instances executions. SDK 3.x accepts factories with createPlugin(info) and creates one plugin instance per invocation. Operation maps, spans, and buffers belong to that invocation; providers, exporters, and serialized export scheduling remain shared by the execution environment.

This implements the selected major migration: explicit and environment providers declaring the legacy v1 pluginApiVersion contract fail with PluginLoadError before their factory or handler runs. Error messages, examples, declarations, and release guidance document migration to a fresh-instance factory. Installed-package and separate-layer tests cover rejection of published v1 providers and successful core 3.0/OTel 2.0 loading. Valid unrelated factories remain supported. The public factory signature permits null or undefined to skip an invocation, matching runtime behavior; bundled OTel and Insight factories retain concrete return types. Packed-package consumer tests verify both contracts without casts.

The major tree preserves the coordinated minor fixes: successful replay export suppression (#953), wrapper UpdatedOperationIds (#954), invocation-local LMI carriers (#955), and exclusive OTel views (#956). An available empty/undefined/null runtime carrier suppresses stale process environment data, while contexts without that capability retain legacy fallback. Optional carrier getters and proxy capability checks are read once only when a factory is configured; failures produce an authoritative empty carrier without aborting the handler. The factory and invocation hooks receive the same snapshot. Raw carrier helpers accept these states under exact optional-property checking. Factory exclusivity uses namespaced symbol metadata and rejects conflicts before constructing plugins.

Both OTel views observe fresh external terminal updates at startup and checkpoint changes. Normal traversal exports pending completions under the active parent, and invocation-end cleanup flushes any still-unobserved completion before ending the invocation. Per-invocation dedup preserves normal replay behavior while allowing failed-invocation redelivery; no acknowledgement or unbounded execution cache is persisted. Error events preserve supplied backend completion timestamps, and missing timestamps use one captured terminal time for both event and span end.

Physical invocation boundaries use explicit HrTime from bounded wall/monotonic correlation samples. Missing logical operation/attempt boundaries use coherent millisecond precision; wall ticks are accepted only inside the measured correlation interval, while supplied backend Date objects stay authoritative. Active deferred ancestors include the actual earliest observed child boundary, with cycle protection and no revival of ended parents. Invocation ends cover observed SDK boundaries without fixed padding or cross-invocation caches. The README documents arbitrary cross-container clock-skew limits. Shared validators and strict SDK relationships are unchanged.

Conformance #960's common helper and handlers 21–24 use the factory API, with both shared workflow pins at 98b802cdb172614f98e217f7464784d47b9bb484. Polling replay links follow #964: attempts link to Workflow; resumed operations link to their initial operation and Workflow. Current main 333ddd606301b5820e7488f015ca745b4a9d8412, including the MicroVM examples and integration wiring, is integrated. Insight keeps late nonterminal updates, drains changes arriving during export, and rejects late running snapshots after terminal state.

Validation:

  • All 565 OTel cases validated: 27 suites passed in the full run; the remaining 38-test clock suite passed after adapting four minor-instance cleanup assertions to verify fresh major-instance isolation. Production source remained unchanged during that test adaptation. Includes installed-package/layer, carrier, lifecycle, clock, and public SDK replay coverage.
  • Factory type follow-up: 109 core factory/loader/lifetime, 63 focused OTel/installed-consumer, and all 112 Insight tests pass. Core: all 1,375 tests pass on the final carrier correction, including eight public-wrapper regressions that failed before the fix. Ten OTel carrier tests and all 27 new-main image/template tests also pass, as do the full root build, strict TypeScript check and 1,202-file Biome check. Prior unchanged testing/Insight/example suites passed (1,233 testing tests plus 3 existing skips; 112 Insight; 245 example Jest tests plus 1 existing skip and 6 snapshots; 2 Vitest).
  • All 12 actual unchanged handler cases 6/7/8/21/22/23 pass every shared telemetry assertion in both views. The invocation case21 has nine spans and the execution case21 has eight. Cloud case24 uses the real service retry; its handler and serializer configuration are unchanged.
  • Core/OTel/Insight/examples/conformance types, ESM/CJS/declaration and example builds, root Biome, and diff checks pass. Six runtime-carrier example tests pass, including all five carrier-availability states through real wait/resume. Exact-optional packed-consumer tests isolate pre-existing released-core logger declaration errors; existing full declaration checks remain enabled.
  • Shared CI prerequisite fix(ci): stabilize deployment and example checks #959 includes 57 deployment budget/retry regressions and reviewed promise/callback expectation corrections. Those example fixes preserve exact operation counts and terminal errors/results; all 12 focused tests and example type checks pass.

Core 3.0.0, OTel 2.0.0, and Insight 0.1.0-alpha.1 use the matching major contract. Testing 1.3.2 supports core >=1.0.1 <4.0.0. Follow minor-before-major release order. Fresh CI and review must pass on the final published head before merge. No merge or release is performed; model-blocked propagation draft #957 is not included.

Refs #950. Integration dependencies: #953, #954, #955, #956, #959, #960, #962, #964.

@ParidelPooya
ParidelPooya changed the base branch from fix/insight-per-execution-export to main September 17, 2026 17:14
The SDK creates one plugin instance per handler-module initialization, so a
single Workflow Insight plugin serves every execution its environment hosts.
Lambda Managed Instances makes concurrent executions in one environment
routine.

The export scheduler held one pending record for the whole plugin and
overwrote it regardless of which execution the record belonged to. Measured on
this package before the change, 20 trials per case: with N concurrent
executions in on-complete mode exactly 2 terminal records were delivered and
N-2 were lost (8 of 10 lost at N=10); in on-change mode 2 concurrent
executions lost a terminal record in 20 of 20 trials. drain() returned
successfully after exporting some other execution's record, so the loss was
silent.

Each execution now owns its pending record, its queue position and its drain
signal, so coalescing happens only within one execution and drain(executionArn)
returns only once that execution's own latest record has reached every
exporter. Exporter calls stay serialized per plugin instance: at most one
export at a time, and a flush is served by the same pump, so an exporter never
sees flush() overlap export().

Also fixed, each found while reviewing the change above:

- A synchronous throw from export(), render() or truncateRecord() escaped the
  fan-out before Promise.allSettled, hung the invocation to its timeout, left
  the scheduler unable to start another pump for any later execution, and
  produced an unhandled rejection.
- The pump re-armed itself with a synchronous self-call, so a queue of failing
  fan-outs grew the stack one frame pair per execution, overflowed, and
  silently abandoned the tail.
- A throw between closing an execution's scope and removing it left a closed
  scope in the map, muting that execution for the life of the environment.
- Per-execution state survived a non-terminal (PENDING) invocation end, pinning
  the whole cached execution input until the environment died. State is now
  dropped at every invocation end and rebuilt from InvocationStartInfo.
- An operation-change hook arriving after onInvocationEnd emitted a RUNNING
  record after the terminal one, reverting a finished execution for every
  exporter that upserts by execution ARN.
- startTime trusted an un-normalized SDK value and fell back to now, so a
  resumed execution's durationMs covered only its last invocation. An Invalid
  Date now degrades one field instead of throwing out of record building.
- InsightExporter.flush() had no documented contract. It now states the
  cadence, the exclusivity guarantee, that a flush may cover other executions'
  records, and how failures are handled.

No public type or exported symbol changed. Record shape, emit modes, sampling,
per-exporter truncation and the default exporter are unchanged.
@ParidelPooya
ParidelPooya force-pushed the refactor/per-invocation-plugin-instances branch from 05b9d11 to acad423 Compare September 17, 2026 17:20
@ParidelPooya ParidelPooya changed the title refactor(plugin)!: create one plugin instance per invocation refactor(plugin): create one plugin instance per invocation Sep 17, 2026
A plugin instance used to live as long as the execution environment while
serving every execution that landed on it. Under Lambda Managed Instances
several executions run concurrently in one environment, so every plugin had
to key its own state by execution ARN and clean that map up itself. Getting
that wrong loses telemetry silently, and both bundled plugins had got it
wrong.

The SDK now builds one plugin per invocation and drops it when the invocation
ends. `plugins` takes factories, not instances:

  plugins: [(info) => new MyPlugin(sharedExporter, info.executionArn)]

A factory is a bare function taking `InvocationInfo` and returning a plugin.
Errors it throws are contained exactly like errors thrown by a hook: the
invocation proceeds without that plugin. Environment-lifetime state stays in
the closure or in a factory object; per-invocation state becomes ordinary
instance fields.

This deletes the provider path rather than adding a parallel one. While an
instance path exists a plugin cannot delete its ARN-keyed map, which is the
entire point of the change. Removed: `DurableInstrumentationPluginProvider`,
`DurableInstrumentationPluginType`, and
`DURABLE_INSTRUMENTATION_PLUGIN_API_VERSION`. Added:
`DurableInstrumentationPluginFactory`. `durableExecutionPluginProvider` is now
a factory, so environment-based loading works unchanged.

Workflow Insight now holds no ARN-keyed structure at all. It also drops
`wrapInvocation` and drains from `onInvocationEnd`, which the SDK awaits at
every exit including the config-error path that `wrapInvocation` missed.

The OTel plugin resolves the tracer provider once per environment and keeps
each invocation's spans in plain fields, so `resetInvocationState` is gone.

BREAKING CHANGE: `plugins` accepts factories instead of plugin instances, and
the provider interface is removed. Pass `(info) => new MyPlugin()` where you
passed `new MyPlugin()`, and export a factory where you exported a provider.
Not migrating no longer degrades quietly: a plugin instance, or the plugin
class itself, in `plugins` now fails every invocation with `PluginLoadError` at
handler initialization, rather than leaving the plugin silently absent.
…p failure

Review findings on the per-invocation plugin contract. Both blocking items
share a shape: a failure that used to be loud became silent.

A JavaScript caller who does not migrate passes a plugin instance in `plugins`.
The SDK called it as a factory, the call threw `TypeError`, and the containment
in `createInvocationPluginRunner` discarded the entry and wrote nothing, so the
plugin was absent for the life of the environment with no signal at all. The
loader already validated that an environment-selected provider is callable and
raised `PluginLoadError`, so the two paths disagreed. `loadConfiguredPlugins`
now runs the same check over the explicit entries, naming the offending
position. A factory that throws when called stays contained, because that is
the documented contract; an entry that is not callable at all is a packaging
mistake that would otherwise be rediscovered and swallowed on every invocation.

The OTel environment is built lazily inside the per-invocation factory, so a
throwing `tracerProviderFactory` now throws inside an invocation instead of at
Lambda init. Containment is right, but the attempt repeated: the customer's
factory ran again on every invocation for the life of the environment, and a
factory that builds a span processor before throwing leaked one per invocation.
Creation is now attempted at most once, the failure is logged once, and later
invocations are refused with the remembered error. A working factory is still
resolved exactly once.

Also from the review: `replayLinks` dropped a guard that became unreachable
when `initialOperationLink` stopped returning `undefined`; `describeValue`
reported an array as "an object" and would have said "a undefined"; and the
`flush()` contract now documents that a slow `export` multiplies by environment
concurrency, with every concurrent end paying the whole product, since the pump
exports every awaited record before the shared flush turn.
A caller migrating from the earlier contract is most likely to pass the class,
because `plugins: [MyPlugin]` was once close enough to correct to look right.
`typeof MyPlugin === "function"`, so the callable check added in the previous
commit does not see it. Calling it throws "Class constructor cannot be invoked
without 'new'" on every invocation, and that throw is contained the way any
plugin failure is contained, so the plugin is absent for the life of the
execution environment with nothing said about why.

I had claimed this needed a per-invocation check to detect. That was wrong: the
source text of a class begins with the `class` keyword and nothing else callable
does, so `Function.prototype.toString.call(entry)` settles it at load time. Both
plugin paths now reject it there, and the message says what to pass instead.

The keyword must be followed by whitespace, `{`, or a comment start. Without
that, a shorthand method named `class` stringifies as `class(info) {}` and one
named `classify` as `classify(info) {}`, and both would be mistaken for a class.
`toString` is called through `Function.prototype` rather than through the value,
because a function can carry its own `toString`.

Measured while writing this: a class declaration, a named or anonymous class
expression, `class{}` with no space, a subclass, and a class with a TS 5
decorator all keep the keyword, as does esbuild minifying to ES2022. Downlevelling
to ES5 does not — TypeScript and esbuild both emit `function PlainPlugin() {}` —
so a class transpiled that far is still missed. Such a function called without
`new` returns `undefined` rather than throwing, and the runner already skips a
factory that hands back nothing, so containment remains the backstop for exactly
that case. Nothing further is attempted: the heuristics that would catch it,
inspecting `prototype` descriptors or method enumerability, also reject ordinary
factory functions, and a false rejection at load time would fail an invocation
that would otherwise have worked.
@ParidelPooya
ParidelPooya force-pushed the refactor/per-invocation-plugin-instances branch from e040d90 to d61acce Compare September 17, 2026 20:15
@ParidelPooya ParidelPooya changed the title refactor(plugin): create one plugin instance per invocation refactor(sdk): create one plugin instance per invocation Sep 17, 2026
@ParidelPooya ParidelPooya reopened this Sep 17, 2026
Two findings the Python reviewers raised, checked here and both real.

The first is the version set. Both plugin packages declared a peer range
that admits a core without the factory contract: otel `>=2.4.0` and
insight `>=2.1.0`, against a core at `2.4.0`. This branch deletes the
provider contract, so npm accepts otel with core `2.4.0` and the handler
then fails at initialization. `RELEASING.md:33` puts version numbers in
the pull request rather than in the release workflow, so this commit sets
them: core to `3.0.0` because `plugins` changed element type from
instances to factories, otel to `2.0.0` because its plugin classes are no
longer exported and factory builders replace them, and both peer ranges to
`>=3.0.0`.

Insight goes to `0.1.0-alpha.1` rather than `0.2.0-alpha.0`. Only
`0.1.0-alpha.0` has ever been published, so a minor bump would claim a
released `0.1.0` that does not exist. The prerelease counter is the
smallest step that is greater than the published version, which the
publish script requires.

The testing package moves for a consequential reason rather than a chosen
one. Its range was `>=1.0.1 <=2.4.0`, whose upper bound excludes the core
built here, and its own test asserts the core satisfies that range. It
references the plugin contract nowhere, so core `3.0.0` is compatible with
it and widening the bound is correct. Its version follows, because the
publish script skips a package whose version is unchanged and the widened
range would never reach a consumer.

The existing peer check covered the testing package only. It now scans
every workspace manifest, and for a package that consumes the factory
contract it requires the declared lower bound to name at least the core
major built here. Which packages consume the contract is derived from
their sources rather than from a list, because a list is what gets left
behind.

The second finding is the reentrancy defect the Python plugin had, and my
earlier reading that this plugin was safe was wrong. I judged that
`onOperationChange` builds and schedules in one expression with no `await`
between the `closed` check and the `schedule` call, so nothing could
interleave. Argument expressions evaluate before the call they feed, so
`buildRecord` completes before `schedule` receives its result, and
`buildRecord` calls customer code: the input and output content
transforms, a per-operation result override, and any accessor on a
customer-thrown error. A synchronous re-entry from that code interleaves
without an `await`.

Measured, with a content transform re-entering `onOperationChange` while
the outer frame held the older snapshot: the exporter received
`[['A','B'], ['A']]`, so an exporter that upserts by execution ARN stores
the older snapshot. Measured with the same re-entry into
`onInvocationEnd`: the exporter received a terminal record and then a
stale RUNNING record for a finished execution, which is exactly what the
`closed` flag exists to prevent and cannot, because the outer hook passed
its check before the nested hook set it.

Each invocation now carries a build revision. Building takes the revision,
and the record is scheduled only while that revision is still current, so
a superseded snapshot is dropped instead of overwriting a newer one. A
record is a complete snapshot, so dropping a superseded one loses nothing.
That is the same property that makes the scheduler's per-execution
coalescing sound.

One limit on the second finding, stated because it bounds the severity
rather than the correctness. Reaching the plugin instance from a transform
requires the customer to capture it from their own wrapping factory, which
the new contract permits but does not require. So the reordering is
measured and real, and its reachability from an unmodified handler is not
established.
A reviewer asked for the registration type to be an object with a method
rather than a bare callable, and the reason is future extensibility rather
than taste. A function type has no member to add anything to. So adding a
process-level hook later -- a flush when the execution environment shuts
down, for example -- would have to change the registration type from
function to object, which is a second breaking change on the same public
surface. An object with one method can gain an optional second member
additively, so doing this now costs one break instead of two.

`DurableInstrumentationPluginFactory` is therefore an interface declaring
`createPlugin(info)`, not `(info: InvocationInfo) => Plugin`. The type's
name and the returned type's name are both unchanged, so no identifier
changes meaning. The method is `createPlugin` rather than `newInvocation`
because what it returns is still called a plugin, so the verb and the noun
agree. This is the shape the Java SDK already had, as
`DurableExecutionPluginFactory.createPlugin(InvocationInfo)`, so the three
SDKs now describe one contract.

The generic parameter is kept because it is used: the OTel builders return
`DurableInstrumentationPluginFactory<InvocationOtelPlugin>` and
`<ExecutionOtelPlugin>`, and without it a caller could no longer see which
plugin type they get back. An interface method position is covariant in its
return type the same way the function type was, so nothing weakened.

The loader's shape test replaces `typeof value === "function"` with a
property lookup for a callable `createPlugin`. It is a lookup rather than an
own-property check, so a factory written as a class instance, which carries
the method on its prototype, is accepted -- the runtime check has to accept
exactly what the type accepts. A function carrying a `createPlugin` property
is accepted for the same reason. The value is never called.

One check became redundant and is deleted. An earlier commit in this branch
added a load-time heuristic that rejected a class passed where a factory
belongs, reading `Function.prototype.toString` for the `class` keyword,
because `typeof MyPlugin === "function"` meant a class passed the callable
test. A class carries no `createPlugin`, so the shape test rejects it
directly. Deleting the heuristic also retires its documented limitation:
a class transpiled to an ES5 `function` did not stringify as `class` and so
escaped it, and that case is now caught like any other. Its tests move to
the new rejection path rather than being removed, minus three that only
pinned the regex.

Migrated with it: both bundled builders, which now return the object; the
three dynamic-plugin-layer provider fixtures, which exported a bare
function and were genuinely broken rather than merely imprecise; the cjs and
esm integration tests that consume them; 18 conformance plugin handlers, 19
factories; and the prose in two READMEs and four insight design documents.

No handler's observable behaviour changed, and that was established from the
diff rather than assumed. `git diff -w` over the conformance package shows
only wrapper lines added or removed, so every factory body is byte-identical
apart from indentation. No added line contains a backtick, so no template
literal was re-indented. No `plugins:` array was edited, so registration
order is preserved in both handlers that register two.

The conformance and integration packages resolve `@aws/*` through a
worktree-root symlink into a different clone, which is why an earlier pass
could not verify them. Package-local symlinks were added under `node_modules`
so they compile against this worktree; `node_modules` is gitignored, so none
of that is committed.
onInvocationStart set tracingEnabled from ensureTracingEnabled at the top,
then called the customer-supplied contextExtractor, and only assigned
executionTraceId afterwards. An extractor that throws therefore left the
instance enabled with no trace identity: the plugin runner contains the
rejected hook, the invocation continues, and every later hook passes its
tracingEnabled check. A cross-invocation replay or continuation span then
linked against an empty trace ID.

This PR removed the guard that used to catch that, on the reasoning that
the link is always constructible. Its span ID is; its trace ID is not.

The flag is now set last, so it means what every later hook reads it to
mean: this invocation's trace identity is resolved.
The pump claims the pending flush resolvers with splice(0) before it
front-loads the records other ends are waiting on. From that point they are
not in flushWaiters, so nothing else can find them and no later pump serves
them. The front-load await sat outside the try that releases them, and
exportPending is a try/finally with no catch, so a synchronous throw from
its fan-out escaped and stranded every waiter. Because onInvocationEnd is
awaited before the Lambda response, those invocations hung to the function
timeout.

The claim and the release are now the same critical section. A flush that
did not run is the same answer to a waiter as a flush that failed, which is
what the per-exporter containment in flushAll already establishes.

A comment claiming that exporters.map runs inside the pump's guarded region
is corrected: it runs inside a finally, not a catch.
Both plugin packages declared >=3.0.0 on the core with no ceiling. That is
the shape of the defect this PR already fixed once: otel declared >=2.4.0
against a core whose 2.4.0 could not load the factory contract, npm
accepted it, and the handler failed at initialization. The next core major
that changes the plugin contract reproduces it exactly.

Both are now >=3.0.0 <4.0.0, and the monorepo guard asserts a
contract-bound range rejects the next major rather than only accepting the
current one.

The testing package had the mirror problem: >=1.0.1 <=3.0.0 excludes core
3.0.1, so the first core patch would have put its claim out of date and its
own guard red, for a package that touches the plugin contract nowhere. It
is now >=1.0.1 <4.0.0, and a new case asserts every declared range admits
the next core patch.
The lint job checks formatting; the local check I ran filtered to
error-level diagnostics, which excludes it.
Retain core SDK 3.0.0 and the plugin-compatible peer ranges while
incorporating main's SDK 2.5.0 and testing SDK 1.2.0 changes. Advance
the testing package to 1.2.1 and synchronize lockfile peer metadata.

Update the plugin-lifetime checkpoint mock for the new token-revocation
and execution-result methods introduced on main.

Validation: full build, repository lint checks, source and conformance
type checks, core SDK (1346), testing SDK (1227; 3 skipped), OTel (360),
Insight (108), OTel conformance handlers (35), and CJS/ESM integration.
Keep the core SDK at 3.0.0 and preserve the plugin-compatible peer ranges. Advance the testing SDK to 1.3.1 after main moved to 1.3.0, with the matching lockfile version.
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 7, 2026 19:33 — with GitHub Actions Active
@github-actions

This comment has been minimized.

@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 7, 2026 21:04 — with GitHub Actions Active
Comment on lines +409 to +410
this.invocationEnded = true;
await this.flushExternalCompletions();

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the first-flush window with a blocked provider flush and a completion 25 ms later: the execution view on terminal completion and the invocation view on both pending and terminal completion ended their enclosing spans too early.

014319d keeps those spans open through an initial serialized drain/flush, drains updates received during that flush and its microtask handoff, then ends the parents and performs the final serialized flush in both views. The three regressions now pass; all 60 focused controls and all 591 OTel tests pass, along with build/type generation and lint. Existing flush-error isolation, timestamps, IDs, sampling and deduplication are preserved.

This is a bounded improvement, not a claim that every late completion is contained. A new update arriving during the final flush or after onInvocationEnd still cannot extend an immutable enclosing span; three negative diagnostics for that later window remain reproducible. The README explicitly documents that limitation and the cost of two flush passes even without updates. This change does not introduce parentless/multiple roots or a different trace topology, and does not promise delivery after Lambda stops executing.

Comment thread packages/aws-durable-execution-sdk-js/src/utils/plugin/plugin-loader.ts Outdated
@github-actions

This comment has been minimized.

@zhongkechen
zhongkechen had a problem deploying to ai-pr-review-runtime October 7, 2026 22:54 — with GitHub Actions Failure
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 7, 2026 23:05 — with GitHub Actions Active
Comment on lines +413 to +414
this.invocationEnded = true;
await this.flushExternalCompletions();

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the final-flush/post-end window explicitly retained in #924 (comment) and the README. The blocked-final-flush diagnostic still reproduces three containment failures; it is not marked fixed. The first-flush improvement has 60 focused and 591 full OTel tests passing, while preserving IDs, timestamps, sampling and the current trace topology.

A final provider flush alone does not prevent a new checkpoint hook from arriving after parent spans have become immutable. Dropping those observed late completions would undo the delivery repair. This PR keeps the bounded preflush improvement and states the remaining lifecycle limitation rather than claiming complete quiescence or introducing a different root topology.

Comment thread packages/aws-durable-execution-sdk-js-insight/src/index.ts
@github-actions

This comment has been minimized.

@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 7, 2026 23:57 — with GitHub Actions Active
// 4. Always flush TracerProvider at invocation boundaries
if ("forceFlush" in this.tracerProvider) {
this.invocationEnded = true;
await this.flushExternalCompletions();

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same documented final-flush/post-end limitation addressed in #924 (comment) . I verified that the subsequent Insight and README commits do not change either OTel implementation. The three blocked-final-flush negative cases remain reproducible and are not marked fixed.

The first-flush improvement and delivery of observed late completions are retained; replacing the final drain with only a provider flush cannot prevent a new checkpoint hook after immutable parent ends. The README explicitly limits containment in that window. This change does not drop those updates or introduce a different trace/root topology.

@github-actions

This comment has been minimized.

@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 8, 2026 18:41 — with GitHub Actions Active
// 4. Always flush TracerProvider at invocation boundaries
if ("forceFlush" in this.tracerProvider) {
this.invocationEnded = true;
await this.flushExternalCompletions();

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same acknowledged final-flush/post-end boundary documented in the README and explained at #924 (comment) . I checked 3b91b1f: both production plugin files are byte-identical to the previously validated implementation; the base merge adds only the four basic clock controls. The blocked-final-flush diagnostic cases were already reproduced and deliberately retained as negative evidence, rather than a claim that late containment is fixed.

The bounded preflush drains and flushes completions before enclosing spans freeze. Updates arriving during the final flush or after the end hook remain outside that containment guarantee. Simply stopping the drain would drop those completion exports; it would not establish quiescence against later updates. The current design keeps the documented limitation and does not claim delivery after the Lambda runtime stops or introduce a different root topology.

@github-actions

This comment has been minimized.

@zhongkechen
zhongkechen deployed to ai-pr-review-runtime October 9, 2026 15:46 — with GitHub Actions Active
// 4. Always flush TracerProvider at invocation boundaries
if ("forceFlush" in this.tracerProvider) {
this.invocationEnded = true;
await this.flushExternalCompletions();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review · Finding arf_v1_ksc4n5ohubqpxar25g6upvizoe

P2 — Do not drain completions after closing their parent spans. The Invocation span and, for terminal executions, Workflow span have already ended here. If onOperationChange queues a completion while this final flush is blocked, drainAndFlush exports it afterward, potentially giving the child a live end time outside its parent interval. Coordinate shutdown so completion delivery is quiescent before ending enclosing spans, then call only flushTracerProvider() afterward; defer unsynchronized completions to redelivery. Apply the same ordering to InvocationOtelPlugin and add a test that blocks the final/second forceFlush().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the known final-flush/post-end containment limitation documented under “External completions and replay” and discussed in #924 (comment) . I checked c462e85: the base integration preserves the existing completion drain, change-hook handling, and final serialized flush. It adds the managed-sampler root behavior without changing that shutdown boundary.

The first drain/flush keeps enclosing spans open for completions delivered during that pass. It does not establish quiescence for an independent notification arriving during the final flush or after the end hook; immutable parent spans cannot then be extended. The blocked-final-flush cases remain negative diagnostic evidence, not a claim that this window is fixed.

Stopping the final drain and relying on redelivery would discard a received completion without a guaranteed later delivery or persisted export acknowledgement. This PR therefore retains the bounded preflush behavior and the explicit late-containment limitation, preserving timestamps, IDs, sampling, and deduplication. It does not claim delivery after the Lambda runtime stops or introduce another root topology to satisfy containment.

@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Codex AI review

Found one P2 telemetry lifecycle regression. Tests do not cover a completion arriving during the final provider flush.

Reviewed commit c462e85945dd0eba89096de05314e7c84fe0c73a. Workflow run

This branch was successfully deployed

1 active deployment
ai-pr-review-runtime — c462e859 Deployed Oct 9, 2026 by zhongkechen via ai-pr-review / Codex review / Generate Codex review #1019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Shared OTel plugin state mixes concurrent LMI invocations

3 participants