Title: Memory leak in graphql subscriptions · Issue #4314 · graphql-java/graphql-java · GitHub
Open Graph Title: Memory leak in graphql subscriptions · Issue #4314 · graphql-java/graphql-java
X Title: Memory leak in graphql subscriptions · Issue #4314 · graphql-java/graphql-java
Description: Describe the bug We are tracking down a memory leak in our graphql subscription server. Bellow is a heap dump of the java process. It seems that per level dataloader is allocated per each subscription message and only released when subsc...
Open Graph Description: Describe the bug We are tracking down a memory leak in our graphql subscription server. Bellow is a heap dump of the java process. It seems that per level dataloader is allocated per each subscript...
X Description: Describe the bug We are tracking down a memory leak in our graphql subscription server. Bellow is a heap dump of the java process. It seems that per level dataloader is allocated per each subscript...
Opengraph URL: https://github.com/graphql-java/graphql-java/issues/4314
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"Memory leak in graphql subscriptions","articleBody":"**Describe the bug**\nWe are tracking down a memory leak in our graphql subscription server. Bellow is a heap dump of the java process. It seems that per level dataloader is allocated per each subscription message and only released when subscription ends.\n\n\u003cimg width=\"705\" height=\"664\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/7f3c5324-31fe-4648-b711-58024f2922ba\" />\n\n\nIt seems like the dataloader strategy is allocation a record per event which never gets cleared for subscription duration. Summary from Claude code:\n\n```\n PerLevelDataLoaderDispatchStrategy and ExhaustedDataLoaderDispatchStrategy both maintain a ConcurrentHashMap\u003cAlternativeCallContext, CallStack> field called alternativeCallContextMap.\n For each subscription event, SubscriptionExecutionStrategy.executeSubscriptionEvent() calls newSubscriptionExecution() which adds a new entry, but the entry is never removed after\n subscriptionEventCompletionDone() completes.\n```\n\n\n**To Reproduce**\n\nWe are using graphql-java 25.0 through netflix-dgs. We have ticker mode enabled for dataloaders. \n\n```\npackage com.opensea.materialization.services.graphqlapi.lib.execution\n\nimport graphql.ExecutionInput\nimport graphql.Profiler\nimport graphql.execution.CoercedVariables\nimport graphql.execution.ExecutionContextBuilder\nimport graphql.execution.ExecutionId\nimport graphql.execution.incremental.AlternativeCallContext\nimport graphql.execution.instrumentation.dataloader.PerLevelDataLoaderDispatchStrategy\nimport graphql.language.OperationDefinition\nimport graphql.parser.Parser\nimport graphql.schema.idl.RuntimeWiring\nimport graphql.schema.idl.SchemaGenerator\nimport graphql.schema.idl.SchemaParser\nimport io.kotest.matchers.shouldBe\nimport java.util.Locale\nimport java.util.concurrent.ConcurrentHashMap\nimport org.dataloader.DataLoaderRegistry\nimport org.junit.jupiter.api.Test\n\n/**\n * Reproducer for a memory leak in graphql-java v25's PerLevelDataLoaderDispatchStrategy.\n *\n * Each subscription event adds a CallStack entry to an internal ConcurrentHashMap\n * (`alternativeCallContextMap`) via [newSubscriptionExecution], but the entry is never\n * removed after [subscriptionEventCompletionDone] completes. This causes unbounded heap\n * growth for long-lived subscriptions.\n *\n * The test calls the strategy methods exactly as SubscriptionExecutionStrategy does,\n * then inspects the map size via reflection.\n */\nclass SubscriptionDispatchStrategyLeakReproducerTest {\n\n @Test\n fun `alternativeCallContextMap grows by one entry per subscription event and is never cleaned up`() {\n val eventCount = 1_000\n val strategy = createStrategy()\n\n // Simulate what SubscriptionExecutionStrategy.executeSubscriptionEvent() does\n // for each subscription event: it creates a fresh AlternativeCallContext(1, 1),\n // calls newSubscriptionExecution, executes the selection set, then calls\n // subscriptionEventCompletionDone.\n for (i in 1..eventCount) {\n val callContext = AlternativeCallContext(1, 1)\n strategy.newSubscriptionExecution(callContext)\n strategy.subscriptionEventCompletionDone(callContext)\n }\n\n // Inspect the internal map via reflection\n val map = getAlternativeCallContextMap(strategy)\n\n // BUG: the map should be empty after all events complete, but it retains\n // every entry — one per subscription event. This is the memory leak.\n //\n // Expected (fixed): map.size == 0\n // Actual (bug): map.size == eventCount\n println(\"alternativeCallContextMap.size after $eventCount events: ${map.size}\")\n println(\"Expected: 0 (entries should be removed after completion)\")\n println(\"Actual: ${map.size} (leaked — never cleaned up)\")\n\n map.size shouldBe eventCount // documents the bug\n }\n\n @Test\n fun `leak scales linearly - each event adds ~1 CallStack with internal ConcurrentHashMaps`() {\n val strategy = createStrategy()\n\n // Measure heap impact: each leaked CallStack contains multiple ConcurrentHashMaps\n // (stateForLevelMap, dispatchedLevels, chainedDLStack.stateMapPerLevel).\n val counts = listOf(100, 500, 1_000)\n var prevSize = 0\n\n for (count in counts) {\n for (i in prevSize until count) {\n val callContext = AlternativeCallContext(1, 1)\n strategy.newSubscriptionExecution(callContext)\n strategy.subscriptionEventCompletionDone(callContext)\n }\n val map = getAlternativeCallContextMap(strategy)\n println(\"After $count events: map.size = ${map.size}\")\n map.size shouldBe count\n prevSize = count\n }\n }\n\n private fun createStrategy(): PerLevelDataLoaderDispatchStrategy {\n val sdl = \"type Query { dummy: String }\"\n val schema =\n SchemaGenerator()\n .makeExecutableSchema(\n SchemaParser().parse(sdl),\n RuntimeWiring.newRuntimeWiring()\n .type(\"Query\") { it.dataFetcher(\"dummy\") { \"ok\" } }\n .build(),\n )\n\n val document = Parser.parse(\"{ dummy }\")\n val input =\n ExecutionInput.newExecutionInput()\n .query(\"{ dummy }\")\n .dataLoaderRegistry(DataLoaderRegistry())\n .build()\n\n val executionContext =\n ExecutionContextBuilder.newExecutionContextBuilder()\n .executionId(ExecutionId.generate())\n .graphQLSchema(schema)\n .executionInput(input)\n .graphQLContext(input.graphQLContext)\n .dataLoaderRegistry(input.dataLoaderRegistry)\n .document(document)\n .operationDefinition(\n document.definitions.first() as OperationDefinition\n )\n .coercedVariables(CoercedVariables.emptyVariables())\n .locale(Locale.getDefault())\n .profiler(Profiler.NO_OP)\n .build()\n\n return PerLevelDataLoaderDispatchStrategy(executionContext)\n }\n\n private fun getAlternativeCallContextMap(\n strategy: PerLevelDataLoaderDispatchStrategy,\n ): ConcurrentHashMap\u003c*, *> {\n val field = strategy.javaClass.getDeclaredField(\"alternativeCallContextMap\")\n field.isAccessible = true\n return field.get(strategy) as ConcurrentHashMap\u003c*, *>\n }\n}\n\n```","author":{"url":"https://github.com/snuderl","@type":"Person","name":"snuderl"},"datePublished":"2026-03-09T16:33:09Z","url":"https://github.com/graphql-java/graphql-java/issues/4314"}
| react-profiling | 0 |
| react-import-map | react,react-dom,react-dom/client,react-dom/profiling,react-is,react-reconciler,react/compiler-runtime,react/jsx-dev-runtime,react/jsx-runtime,scheduler@d5750093facf |
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:97c2c81e-36be-7a74-ee95-196bd520e1af |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | DB6E:1E45AC:9D5BAD:CD1797:6AED3F2B |
| html-safe-nonce | ceb4e736ef01fd3b7bddb42ec6bbacf7acaa25b3f9ff89abfcdf6032ed426a83 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJEQjZFOjFFNDVBQzo5RDVCQUQ6Q0QxNzk3OjZBRUQzRjJCIiwidmlzaXRvcl9pZCI6IjQ4NDUzMjQ4MjM3MTUyMDA4MTEiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 0389a036618fdfc8a45f9a59bd450b58a43b91a1393dcabe42a2edbd72f0c6b9 |
| hovercard-subject-tag | issue:4046618237 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/graphql-java/graphql-java/4314/issue_layout |
| twitter:image | https://opengraph.githubassets.com/0859e2f35c85f94cf8aaf58fe9a0490b5333b0d9c48b6f3b174d974225553b79/graphql-java/graphql-java/issues/4314 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/0859e2f35c85f94cf8aaf58fe9a0490b5333b0d9c48b6f3b174d974225553b79/graphql-java/graphql-java/issues/4314 |
| og:image:alt | Describe the bug We are tracking down a memory leak in our graphql subscription server. Bellow is a heap dump of the java process. It seems that per level dataloader is allocated per each subscript... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | snuderl |
| hostname | github.com |
| expected-hostname | github.com |
| None | 26c92a9388ddfad3fa88d132b5d5f0b4747030233dcc4e5efb05f1cbae152184 |
| turbo-cache-control | no-preview |
| go-import | github.com/graphql-java/graphql-java git https://github.com/graphql-java/graphql-java.git |
| octolytics-dimension-user_id | 14289921 |
| octolytics-dimension-user_login | graphql-java |
| octolytics-dimension-repository_id | 38602457 |
| octolytics-dimension-repository_nwo | graphql-java/graphql-java |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 38602457 |
| octolytics-dimension-repository_network_root_nwo | graphql-java/graphql-java |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | c629ad99c254d306375fc8354d04321c2c362236 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width