Skip to content

fix(server-utils): Cap the runs the LangChain callback handler keeps open - #25595

Merged
RulaKhaled merged 4 commits into
developfrom
fix/langchain-callback-handler-stale-runs
Oct 27, 2026
Merged

RulaKhaled merged 4 commits into
developfrom
fix/langchain-callback-handler-stale-runs

Conversation

@RulaKhaled

@RulaKhaled RulaKhaled commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

@langchain/core never calls the end or error callback when a consumer stops reading a stream. The handler is shared across the process, so those runs stay in its map forever, each keeping its request's span tree in memory.

The map is now an LRUMap capped at 1000 runs, and like Flue, Mastra and pi-durable, an evicted run's span is ended so the tree stays whole. A run is only marked as used when it or a descendant starts, so once leaks fill the map, a long streaming call can be ended early with a cut-off duration. A debug warning fires the first time the map fills.

Fixes #25510

🤖 Generated with Claude Code

…open

`@langchain/core` never calls the end or error callback when a consumer
stops reading a stream, so those runs stayed in the shared handler's map
forever. The map is now an `LRUMap` capped at 1000 runs. An evicted run
is dropped without ending its span, since ending it would send a span
that lasted until eviction.

Fixes #25510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

Compared: 66e7c7a (PR test merge using develop at e13255f)

Against: e13255f (develop) · base run #63743

✅ Baseline matches: this diff contains only this PR's changes.

Path Size % Change Change
@sentry/browser 30.02 kB - -
@sentry/browser - with treeshaking flags 28.16 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 28.06 kB - -
@sentry/browser (incl. Tracing) 52.21 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 52.22 kB - -
@sentry/browser (incl. Tracing, Profiling) 55.14 kB - -
@sentry/browser (incl. Tracing, Replay) 91.98 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 80.89 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 96.72 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 109.7 kB - -
@sentry/browser (incl. Feedback) 47.58 kB - -
@sentry/browser (incl. sendFeedback) 35.09 kB - -
@sentry/browser (incl. FeedbackAsync) 40.19 kB - -
@sentry/browser (incl. Metrics) 31.04 kB - -
@sentry/browser (incl. Logs) 31.32 kB - -
@sentry/browser (incl. Metrics & Logs) 31.99 kB - -
@sentry/react 31.84 kB - -
@sentry/react (incl. Tracing) 54.52 kB - -
@sentry/vue 38.17 kB - -
@sentry/vue (incl. Tracing) 55.12 kB - -
@sentry/svelte 30.04 kB - -
@sentry/remix (Remix 3 client bundle) 57.24 kB - -
CDN Bundle 31.79 kB - -
CDN Bundle (incl. Tracing) 52.73 kB - -
CDN Bundle (incl. Logs, Metrics) 33.99 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 54.7 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 74.96 kB - -
CDN Bundle (incl. Tracing, Replay) 90.45 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 92.36 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 96.56 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 98.53 kB - -
CDN Bundle - uncompressed 93.62 kB - -
CDN Bundle (incl. Tracing) - uncompressed 156.47 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 100.17 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 162.44 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 230.39 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 276.81 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 282.77 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 290.51 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 296.46 kB - -
@sentry/nextjs (client) 57.42 kB - -
@sentry/sveltekit (client) 52.56 kB - -
@sentry/core/server 43.66 kB - -
@sentry/core/browser 13.81 kB - -
@sentry/node 154.26 kB +0.1% +145 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 81.61 kB - -
@sentry/node - without tracing 95.56 kB +0.02% +13 B 🔺
@sentry/node - without channel injection 134.91 kB +0.11% +138 B 🔺
@sentry/aws-serverless 103.73 kB +0.01% +9 B 🔺
@sentry/cloudflare (withSentry) - minified 197.4 kB - -
@sentry/cloudflare (withSentry) 490.4 kB - -
@sentry/nextjs/cloudflare (withSentry) - minified 217.98 kB - -

View base workflow run

@RulaKhaled

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 596ad0a. Configure here.

@RulaKhaled
RulaKhaled marked this pull request as ready for review October 21, 2026 09:23
@RulaKhaled
RulaKhaled requested a review from a team as a code owner October 21, 2026 09:23
@RulaKhaled
RulaKhaled requested review from andreiborza and isaacs and removed request for a team October 21, 2026 09:23
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Optional E2E tests

Some optional checks were not successful. They do not block the merge. 4 not successful, 61 successful. See the run.

Check Status
❌ CI: Optional E2E Tests / E2E node-openai-decisions-send-to-sentry Test (optional) Failing after 3m Details
❌ CI: Optional E2E Tests / E2E node-openai-decisions-send-to-sentry (latest) Test (optional) Failing after 4m Details
❌ CI: Optional E2E Tests / E2E node-anthropic-send-to-sentry (latest) Test (optional) Failing after 21s Details
❌ CI: Optional E2E Tests / E2E node-eve (latest) Test (optional) Failing after 3m Details
Successful checks
Check Status
✅ CI: Optional E2E Tests / E2E cloudflare-flue-send-to-sentry Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E nextjs-15 (turbo) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E cloudflare-agent Test (optional) Successful in 38s Details
✅ CI: Optional E2E Tests / E2E node-eve (orchestrion) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E nextjs-16-tunnel (cloudflare) Test (optional) Successful in 48s Details
✅ CI: Optional E2E Tests / E2E cloudflare-pi-durable Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E astro-7 Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E node-express-mcp-v2 Test (optional) Successful in 18s Details
✅ CI: Optional E2E Tests / E2E astro-7-static Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E cloudflare-pi-durable (latest) Test (optional) Successful in 41s Details
✅ CI: Optional E2E Tests / E2E cloudflare-workers-send-to-sentry Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E deno (latest) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E gen-ai-libraries Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (vinext, bun) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E node-express-mcp-v1 Test (optional) Successful in 19s Details
✅ CI: Optional E2E Tests / E2E debug-id-sourcemaps Test (optional) Successful in 30s Details
✅ CI: Optional E2E Tests / E2E cloudflare-think Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (vinext) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (canary, turbopack) Test (optional) Successful in 6m Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (canary, webpack) Test (optional) Successful in 6m Details
✅ CI: Optional E2E Tests / E2E nextjs-orpc Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E tanstackstart-react-cloudflare Test (optional) Successful in 31s Details
✅ CI: Optional E2E Tests / E2E cloudflare-mastra Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E cloudflare-think (ai v6) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E node-openai-send-to-sentry Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E node-express-send-to-sentry-static Test (optional) Successful in 59s Details
✅ CI: Optional E2E Tests / E2E node-flue (latest) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E vue-3 (canary) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E angular (canary) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E node-mastra (latest) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E nuxt-5-sourcemaps Test (optional) Successful in 34s Details
✅ CI: Optional E2E Tests / E2E nuxt-5-sourcemaps (client source maps kept) Test (optional) Successful in 33s Details
✅ CI: Optional E2E Tests / E2E nextjs-14 (latest) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E solidstart-2 Test (optional) Successful in 45s Details
✅ CI: Optional E2E Tests / E2E node-eve Test (optional) Successful in 54s Details
✅ CI: Optional E2E Tests / E2E nextjs-14 (canary) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (cloudflare, latest) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E node-pi-durable (latest) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E node-fastify-6 (tracer provider) Test (optional) Successful in 42s Details
✅ CI: Optional E2E Tests / E2E nextjs-16-standalone (canary, turbopack) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nuxt-3 (canary) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E node-fastify-6 Test (optional) Successful in 28s Details
✅ CI: Optional E2E Tests / E2E gen-ai-libraries (cloudflare) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nuxt-4-static (canary) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E node-mastra Test (optional) Successful in 48s Details
✅ CI: Optional E2E Tests / E2E node-pi-durable Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nuxt-5-sourcemaps (static / nuxt generate) Test (optional) Successful in 31s Details
✅ CI: Optional E2E Tests / E2E nuxt-5 Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E node-anthropic-send-to-sentry Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E node-flue Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E react-router-7-framework-instrumentation Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E angular (canary) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E node-openai-send-to-sentry (latest) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E react-send-to-sentry Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (deno) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nextjs-15 (latest) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E node-express-send-to-sentry Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E supabase-nextjs Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nuxt-4 (canary) Test (optional) Successful in 4m Details
✅ CI: Optional E2E Tests / E2E tanstackstart-react-sourcemaps Test (optional) Successful in 52s Details
✅ CI: Optional E2E Tests / E2E vue-3 (no Options API) Test (optional) Successful in 1m Details

@isaacs isaacs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is the smallest change that bounds memory: swap Map for LRUMap, reuse the 1000 cap that the other AI integrations use, and turn the ancestor walk into a single getAncestors() pass so that every ancestor gets an LRU touch. That refactor also lets getParentRunSpan and the conversation-id backfill share one walk, which reads a bit cleaner than the two loops before.

The open design question is what eviction should mean. This PR drops the span. The sibling integrations end it. Under steady leaks the map stays full, so eviction is probably not rare: it affects live runs whose last touch is older than the last 1000 run starts.

I would suggest we settle the drop-vs-end policy across the package, and consider a larger cap or dead-run-first eviction. This code itself is correct for what it sets out to do, and the tests guard the key behavior. 👍


// Internal state - single instance tracks all spans
const runs = new Map<string, LangChainRun>();
const runs = new LRUMap<string, LangChainRun>(MAX_TRACKED_LANGCHAIN_RUNS);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Flue, Mastra, and pi-durable all use the same 1000 cap, but each one ends the oldest span before eviction. Their comments call out that LRUMap.set would otherwise drop it unended. This PR deliberately does the opposite, with the reason "ending it would send a span that lasted until eviction".

Ending on eviction gives a truncated span with wrong duration, but keeps the tree whole. Either policy can be right, but the package should pick one, so users of different AI integrations don't see different failures under the same pressure.

Suggestion: either follow the sibling pattern (check runs.size >= MAX_TRACKED_LANGCHAIN_RUNS, remove the oldest key and end() it before set), or, if the we decide we prefer dropping, say so in the PR body and open a follow-up to align Flue, Mastra and pi-durable. At minimum, explain in the PR why LangChain differs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

you're right, I'd misread mastra (looked at the span registry, not _trackSpan). switched to ending the evicted run like flue/mastra/pi-durable. so the package has one policy, and an evicted live run stays in the tree with a cut-off duration instead of disappearing.

}

const run: LangChainRun = { span, parentRunId, activeSpan, conversationId };
runs.set(runId, run);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This can drop live runs ahead of leaked ones. Leaked runs are never removed except by eviction. In a process that leaks steadily, the map reaches 1000 entries and stays full.

From then on, every runs.set() evicts the least recently touched entry. That is a leaked run only while all leaked runs are older than all live ones. Leaks that happen after a live run's last touch are newer than it, so the live run goes first.

A run is touched only when it starts, when a descendant starts, and in its own end/error handler. There is no handleLLMNewToken handler, so a chat model streaming for a long time with no child runs is not touched. On a busy server with a saturated map, the effective rule becomes "a run survives only if it was touched within the last 1000 run starts across the process." For example, one 60s streamed chat call, while other requests start 20 runs per second, would be evicted after about 50s and silently lost.

1000 open runs also bounds legitimate concurrency. Each in-flight agent request holds its current path open (often 3 to 6 runs), so a few hundred concurrent requests can push live runs out even with no leaks.

Suggestion: consider one or more of these.

  • Prefer evicting runs that are known to be dead. For example, before set on a full map, drop entries whose root span (getRootSpan(run.span)) is no longer recording. An abandoned stream inside a finished request then goes before a live run. This was also one of the options suggested in LangChain callback handler keeps runs that never end in memory #25510, but it's a bit more complicated of course.
  • Raise the cap maybe? Entries are fairly small, and the goal is to bound a leak, not to keep memory tight. 10,000 might still be a fine.
  • Emit a DEBUG_BUILD debug.warn when the cap is hit, so a user who sees missing LangChain spans has some signal.

Or, if the we go with the current tradeoff, say so in the PR body so the decision is explicit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

confirmed, nothing touches a streaming chat run. added a one time debug.warn when the map first fills, so missing/cut-short spans have a signal. kept the cap at 1000 to match the siblings. dead-run-first eviction doesn't help leaked runs that are their own root, so I'd rather do it package wide if we want it. noted the tradeoff in the description.

…rations

Flue, Mastra and pi-durable all end the span they evict, so LangChain now
does too instead of dropping it unended. A debug warning fires the first
time the run map fills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RulaKhaled
RulaKhaled enabled auto-merge (squash) October 27, 2026 16:02
@RulaKhaled
RulaKhaled merged commit 1043c1c into develop Oct 27, 2026
686 of 692 checks passed
@RulaKhaled
RulaKhaled deleted the fix/langchain-callback-handler-stale-runs branch October 27, 2026 16:44
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.

LangChain callback handler keeps runs that never end in memory

2 participants