From 30a5635d425fde435e1e40f87f4ea62a0141feec Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Fri, 29 May 2026 17:38:44 +0530 Subject: [PATCH 1/7] feat(spanner): wrap OTel background maintenance setInterval in ROOT_CONTEXT to isolate and prevent memory leaks --- handwritten/spanner/src/multiplexed-session.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/handwritten/spanner/src/multiplexed-session.ts b/handwritten/spanner/src/multiplexed-session.ts index fdcc6675ceb6..8a11a8f287df 100644 --- a/handwritten/spanner/src/multiplexed-session.ts +++ b/handwritten/spanner/src/multiplexed-session.ts @@ -15,6 +15,7 @@ */ import {EventEmitter} from 'events'; +import {context, ROOT_CONTEXT} from '@opentelemetry/api'; import {Database} from './database'; import {Session} from './session'; import {GetSessionCallback} from './session-factory'; @@ -177,8 +178,10 @@ export class MultiplexedSession clearInterval(this._refreshHandle); } const refreshRate = this.refreshRate! * 24 * 60 * 60000; - this._refreshHandle = setInterval(async () => { - await this._createSession().catch(() => {}); + this._refreshHandle = setInterval(() => { + context.with(ROOT_CONTEXT, async () => { + await this._createSession().catch(() => {}); + }); }, refreshRate); // Unreference the timer so it does not prevent the Node.js process from exiting. From 75bb97749180e652eccb5fd1d8a0046a88e758e7 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Fri, 29 May 2026 17:47:50 +0530 Subject: [PATCH 2/7] feat(spanner): wrap legacy SessionPool setInterval pings and evictions in ROOT_CONTEXT to isolate and prevent memory leaks --- handwritten/spanner/src/multiplexed-session.ts | 4 ++-- handwritten/spanner/src/session-pool.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/handwritten/spanner/src/multiplexed-session.ts b/handwritten/spanner/src/multiplexed-session.ts index 8a11a8f287df..1f60ab65a297 100644 --- a/handwritten/spanner/src/multiplexed-session.ts +++ b/handwritten/spanner/src/multiplexed-session.ts @@ -179,8 +179,8 @@ export class MultiplexedSession } const refreshRate = this.refreshRate! * 24 * 60 * 60000; this._refreshHandle = setInterval(() => { - context.with(ROOT_CONTEXT, async () => { - await this._createSession().catch(() => {}); + context.with(ROOT_CONTEXT, () => { + this._createSession().catch(() => { }); }); }, refreshRate); diff --git a/handwritten/spanner/src/session-pool.ts b/handwritten/spanner/src/session-pool.ts index 3c80e5bdceb2..662ac2a1957f 100644 --- a/handwritten/spanner/src/session-pool.ts +++ b/handwritten/spanner/src/session-pool.ts @@ -22,6 +22,7 @@ import {Session} from './session'; import {Transaction} from './transaction'; import {NormalCallback} from './common'; import {GoogleError, grpc, ServiceError} from 'google-gax'; +import {context, ROOT_CONTEXT} from '@opentelemetry/api'; import trace = require('stack-trace'); import { ObservabilityOptions, @@ -1061,12 +1062,16 @@ export class SessionPool extends EventEmitter implements SessionPoolInterface { _startHouseKeeping(): void { const evictRate = this.options.idlesAfter! * 60000; - this._evictHandle = setInterval(() => this._evictIdleSessions(), evictRate); + this._evictHandle = setInterval(() => { + context.with(ROOT_CONTEXT, () => this._evictIdleSessions()); + }, evictRate); this._evictHandle.unref(); const pingRate = this.options.keepAlive! * 60000; - this._pingHandle = setInterval(() => this._pingIdleSessions(), pingRate); + this._pingHandle = setInterval(() => { + context.with(ROOT_CONTEXT, () => this._pingIdleSessions()); + }, pingRate); this._pingHandle.unref(); } From 2f084c41a0e4dfedb889c93578658d264f394d09 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Fri, 29 May 2026 13:11:19 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20po?= =?UTF-8?q?st-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- handwritten/spanner/src/helper.ts | 7 +++++- .../spanner/src/multiplexed-session.ts | 2 +- handwritten/spanner/system-test/spanner.ts | 24 +++++++++++++++---- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/handwritten/spanner/src/helper.ts b/handwritten/spanner/src/helper.ts index e50802bf5c1e..42c67d53e05b 100644 --- a/handwritten/spanner/src/helper.ts +++ b/handwritten/spanner/src/helper.ts @@ -283,5 +283,10 @@ export function isError(value: any): boolean { * @returns {Boolean} `true` if the value is a UUID, otherwise `false`. */ export function isUuid(value: any): boolean { - return typeof value === 'string' && /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i.test(value); + return ( + typeof value === 'string' && + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i.test( + value, + ) + ); } diff --git a/handwritten/spanner/src/multiplexed-session.ts b/handwritten/spanner/src/multiplexed-session.ts index 1f60ab65a297..fee5b4db9427 100644 --- a/handwritten/spanner/src/multiplexed-session.ts +++ b/handwritten/spanner/src/multiplexed-session.ts @@ -180,7 +180,7 @@ export class MultiplexedSession const refreshRate = this.refreshRate! * 24 * 60 * 60000; this._refreshHandle = setInterval(() => { context.with(ROOT_CONTEXT, () => { - this._createSession().catch(() => { }); + this._createSession().catch(() => {}); }); }, refreshRate); diff --git a/handwritten/spanner/system-test/spanner.ts b/handwritten/spanner/system-test/spanner.ts index 7031a7fe49cd..4b774fcf85e3 100644 --- a/handwritten/spanner/system-test/spanner.ts +++ b/handwritten/spanner/system-test/spanner.ts @@ -933,7 +933,11 @@ describe('Spanner', () => { }); it('GOOGLE_STANDARD_SQL should write uuid array values', async () => { - const values = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]; + const values = [ + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + ]; const {row} = await insert( {UUIDArray: values}, Spanner.GOOGLE_STANDARD_SQL, @@ -942,7 +946,11 @@ describe('Spanner', () => { }); it.skip('POSTGRESQL should write uuid array values', async () => { - const values = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]; + const values = [ + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + ]; const {row} = await insert({UUIDArray: values}, Spanner.POSTGRESQL); assert.deepStrictEqual(row.toJSON().UUIDArray, values); }); @@ -4674,7 +4682,11 @@ describe('Spanner', () => { }); it('GOOGLE_STANDARD_SQL should bind arrays', async () => { - const values = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]; + const values = [ + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + ]; const query = { sql: 'SELECT @v', @@ -4714,7 +4726,11 @@ describe('Spanner', () => { }); it.skip('POSTGRESQL should bind arrays', async () => { - const values = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()]; + const values = [ + crypto.randomUUID(), + crypto.randomUUID(), + crypto.randomUUID(), + ]; const query = { sql: 'SELECT $1', From 40011f7d6b838c73b39d2ae8d0182c1e9466f25a Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Fri, 29 May 2026 18:41:10 +0530 Subject: [PATCH 4/7] refactor(spanner): invoke setInterval inside context.with(ROOT_CONTEXT) to prevent OTel AsyncLocalStorage scheduling context leaks --- .../spanner/src/multiplexed-session.ts | 25 +++++++++---------- handwritten/spanner/src/session-pool.ts | 12 ++++----- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/handwritten/spanner/src/multiplexed-session.ts b/handwritten/spanner/src/multiplexed-session.ts index fee5b4db9427..9aee336a1697 100644 --- a/handwritten/spanner/src/multiplexed-session.ts +++ b/handwritten/spanner/src/multiplexed-session.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import {EventEmitter} from 'events'; -import {context, ROOT_CONTEXT} from '@opentelemetry/api'; -import {Database} from './database'; -import {Session} from './session'; -import {GetSessionCallback} from './session-factory'; +import { EventEmitter } from 'events'; +import { context, ROOT_CONTEXT } from '@opentelemetry/api'; +import { Database } from './database'; +import { Session } from './session'; +import { GetSessionCallback } from './session-factory'; import { ObservabilityOptions, getActiveOrNoopSpan, @@ -64,8 +64,7 @@ export interface MultiplexedSessionInterface extends EventEmitter { */ export class MultiplexedSession extends EventEmitter - implements MultiplexedSessionInterface -{ + implements MultiplexedSessionInterface { database: Database; // frequency to create new mux session refreshRate: number; @@ -98,7 +97,7 @@ export class MultiplexedSession }) // Ignore errors here. If this fails, the next user request will // automatically trigger a retry via `_getSession`. - .catch(err => {}); + .catch(err => { }); } /** @@ -178,11 +177,11 @@ export class MultiplexedSession clearInterval(this._refreshHandle); } const refreshRate = this.refreshRate! * 24 * 60 * 60000; - this._refreshHandle = setInterval(() => { - context.with(ROOT_CONTEXT, () => { - this._createSession().catch(() => {}); - }); - }, refreshRate); + this._refreshHandle = context.with(ROOT_CONTEXT, () => + setInterval(() => { + this._createSession().catch(() => { }); + }, refreshRate) + ); // Unreference the timer so it does not prevent the Node.js process from exiting. // If the application has finished all other work, this background timer shouldn't diff --git a/handwritten/spanner/src/session-pool.ts b/handwritten/spanner/src/session-pool.ts index 662ac2a1957f..aa9c49a5fa01 100644 --- a/handwritten/spanner/src/session-pool.ts +++ b/handwritten/spanner/src/session-pool.ts @@ -1062,16 +1062,16 @@ export class SessionPool extends EventEmitter implements SessionPoolInterface { _startHouseKeeping(): void { const evictRate = this.options.idlesAfter! * 60000; - this._evictHandle = setInterval(() => { - context.with(ROOT_CONTEXT, () => this._evictIdleSessions()); - }, evictRate); + this._evictHandle = context.with(ROOT_CONTEXT, () => + setInterval(() => this._evictIdleSessions(), evictRate) + ); this._evictHandle.unref(); const pingRate = this.options.keepAlive! * 60000; - this._pingHandle = setInterval(() => { - context.with(ROOT_CONTEXT, () => this._pingIdleSessions()); - }, pingRate); + this._pingHandle = context.with(ROOT_CONTEXT, () => + setInterval(() => this._pingIdleSessions(), pingRate) + ); this._pingHandle.unref(); } From 0589d59b308bac376c2c1c7d5c426bba7c0d75d1 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Fri, 29 May 2026 13:30:43 +0000 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20po?= =?UTF-8?q?st-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- .../spanner/src/multiplexed-session.ts | 19 ++++++++++--------- handwritten/spanner/src/session-pool.ts | 4 ++-- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/handwritten/spanner/src/multiplexed-session.ts b/handwritten/spanner/src/multiplexed-session.ts index 9aee336a1697..fff38e90409d 100644 --- a/handwritten/spanner/src/multiplexed-session.ts +++ b/handwritten/spanner/src/multiplexed-session.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { EventEmitter } from 'events'; -import { context, ROOT_CONTEXT } from '@opentelemetry/api'; -import { Database } from './database'; -import { Session } from './session'; -import { GetSessionCallback } from './session-factory'; +import {EventEmitter} from 'events'; +import {context, ROOT_CONTEXT} from '@opentelemetry/api'; +import {Database} from './database'; +import {Session} from './session'; +import {GetSessionCallback} from './session-factory'; import { ObservabilityOptions, getActiveOrNoopSpan, @@ -64,7 +64,8 @@ export interface MultiplexedSessionInterface extends EventEmitter { */ export class MultiplexedSession extends EventEmitter - implements MultiplexedSessionInterface { + implements MultiplexedSessionInterface +{ database: Database; // frequency to create new mux session refreshRate: number; @@ -97,7 +98,7 @@ export class MultiplexedSession }) // Ignore errors here. If this fails, the next user request will // automatically trigger a retry via `_getSession`. - .catch(err => { }); + .catch(err => {}); } /** @@ -179,8 +180,8 @@ export class MultiplexedSession const refreshRate = this.refreshRate! * 24 * 60 * 60000; this._refreshHandle = context.with(ROOT_CONTEXT, () => setInterval(() => { - this._createSession().catch(() => { }); - }, refreshRate) + this._createSession().catch(() => {}); + }, refreshRate), ); // Unreference the timer so it does not prevent the Node.js process from exiting. diff --git a/handwritten/spanner/src/session-pool.ts b/handwritten/spanner/src/session-pool.ts index aa9c49a5fa01..bed5e7c322da 100644 --- a/handwritten/spanner/src/session-pool.ts +++ b/handwritten/spanner/src/session-pool.ts @@ -1063,14 +1063,14 @@ export class SessionPool extends EventEmitter implements SessionPoolInterface { const evictRate = this.options.idlesAfter! * 60000; this._evictHandle = context.with(ROOT_CONTEXT, () => - setInterval(() => this._evictIdleSessions(), evictRate) + setInterval(() => this._evictIdleSessions(), evictRate), ); this._evictHandle.unref(); const pingRate = this.options.keepAlive! * 60000; this._pingHandle = context.with(ROOT_CONTEXT, () => - setInterval(() => this._pingIdleSessions(), pingRate) + setInterval(() => this._pingIdleSessions(), pingRate), ); this._pingHandle.unref(); } From ad478dfe9b82c03c7be244002d4ff03ad51d0381 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Fri, 29 May 2026 20:47:39 +0530 Subject: [PATCH 6/7] test(spanner): add unit test programmatically verifying ROOT_CONTEXT scheduling isolation for background timers --- .../spanner/observability-test/spanner.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/handwritten/spanner/observability-test/spanner.ts b/handwritten/spanner/observability-test/spanner.ts index d298c7d7dd3d..555b14bda36e 100644 --- a/handwritten/spanner/observability-test/spanner.ts +++ b/handwritten/spanner/observability-test/spanner.ts @@ -18,6 +18,7 @@ import * as assert from 'assert'; import {grpc} from 'google-gax'; import {google} from '../protos/protos'; import {Database, Instance, Spanner} from '../src'; +import {MultiplexedSession} from '../src/multiplexed-session'; import {MutationSet} from '../src/transaction'; import protobuf = google.spanner.v1; import v1 = google.spanner.v1; @@ -2024,4 +2025,49 @@ describe('End to end tracing headers', () => { txn.end(); } }); + + it('should schedule background timers in ROOT_CONTEXT to prevent trace/memory leaks', async () => { + const originalSetInterval = global.setInterval; + let capturedSchedulingContext: any; + + // Create an active user span context + const otelApi = require('@opentelemetry/api'); + const userTracer = otelApi.trace.getTracer('test-tracer'); + const userSpan = userTracer.startSpan('User.API.Request'); + + try { + await otelApi.context.with(otelApi.trace.setSpan(otelApi.ROOT_CONTEXT, userSpan), async () => { + // Monkey-patch setInterval to capture context exactly at scheduling time + (global as any).setInterval = function (callback: any, ms: any) { + capturedSchedulingContext = otelApi.context.active(); + // Return a mock handle + return { + unref: () => {}, + }; + }; + + // Trigger the MultiplexedSession _maintain() schedule method + // (Accessing private method for targeted unit verification) + const mux = new MultiplexedSession({} as any); + (mux as any)._maintain(); + }); + + // Verify that scheduling DID NOT capture the active user span context, + // but instead securely captured ROOT_CONTEXT + assert.strictEqual( + otelApi.trace.getSpan(capturedSchedulingContext), + undefined, + 'setInterval MUST be scheduled under ROOT_CONTEXT' + ); + assert.strictEqual( + capturedSchedulingContext, + otelApi.ROOT_CONTEXT, + 'setInterval context did not match ROOT_CONTEXT' + ); + } finally { + // Clean up global state + global.setInterval = originalSetInterval; + userSpan.end(); + } + }); }); From 5f5d1c5964b0c0c3a3e9b8316b8b235f9234f03b Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Tue, 2 Jun 2026 11:34:10 +0530 Subject: [PATCH 7/7] test(spanner): isolate MetricsTracerFactory cleanup timer and add comprehensive OpenTelemetry context isolation tests --- .../observability-test/context-isolation.ts | 196 ++++++++++++++++++ .../spanner/observability-test/spanner.ts | 46 ---- .../src/metrics/metrics-tracer-factory.ts | 10 +- 3 files changed, 202 insertions(+), 50 deletions(-) create mode 100644 handwritten/spanner/observability-test/context-isolation.ts diff --git a/handwritten/spanner/observability-test/context-isolation.ts b/handwritten/spanner/observability-test/context-isolation.ts new file mode 100644 index 000000000000..8304a5927890 --- /dev/null +++ b/handwritten/spanner/observability-test/context-isolation.ts @@ -0,0 +1,196 @@ +/*! + * Copyright 2026 Google LLC. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; +import {before, after, beforeEach, afterEach, describe, it} from 'mocha'; +import * as sinon from 'sinon'; +import {context, trace} from '@opentelemetry/api'; +import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node'; +import {AlwaysOnSampler} from '@opentelemetry/sdk-trace-base'; +import {Database} from '../src/database'; +import {SessionPool} from '../src/session-pool'; +import {MultiplexedSession} from '../src/multiplexed-session'; +import {MetricsTracerFactory} from '../src/metrics/metrics-tracer-factory'; +import { + ensureInitialContextManagerSet, + _resetTracingEnabledForTest, +} from '../src/instrument'; + +describe('OpenTelemetry Context Isolation Tests', () => { + const sandbox = sinon.createSandbox(); + let provider: NodeTracerProvider; + + const MOCK_DATABASE = { + batchCreateSessions: sandbox.stub().resolves([[]]), + databaseRole: 'parent_role', + _observabilityOptions: {}, + } as unknown as Database; + + before(() => { + _resetTracingEnabledForTest(); + ensureInitialContextManagerSet(); + + provider = new NodeTracerProvider({ + sampler: new AlwaysOnSampler(), + }); + provider.register(); + _resetTracingEnabledForTest(); + }); + + after(async () => { + await provider.shutdown(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('SessionPool background housekeeping timers', () => { + let sessionPool: SessionPool; + + beforeEach(() => { + sessionPool = new SessionPool(MOCK_DATABASE, { + min: 0, + max: 10, + idlesAfter: 10, + keepAlive: 30, + }); + }); + + afterEach(() => { + sessionPool._stopHouseKeeping(); + }); + + it('should schedule evict and keep-alive setInterval calls in ROOT_CONTEXT', () => { + const tracer = trace.getTracer('test'); + + const setIntervalStub = sandbox + .stub(global, 'setInterval') + .callsFake(() => { + const activeSpan = trace.getSpan(context.active()); + + // Assert that the active context is ROOT_CONTEXT (i.e., no active span) + assert.strictEqual( + activeSpan, + undefined, + 'setInterval scheduling must be isolated within ROOT_CONTEXT and not carry any active request span', + ); + return { + unref: () => {}, + } as unknown as NodeJS.Timeout; + }); + + // Start an active request context + tracer.startActiveSpan('request-span', span => { + try { + // Start housekeeping under the request context + sessionPool._startHouseKeeping(); + } finally { + span.end(); + } + }); + + // Verify that both evict and ping intervals were scheduled + assert.strictEqual(setIntervalStub.callCount, 2); + }); + }); + + describe('MultiplexedSession background maintenance timer', () => { + let multiplexedSession: MultiplexedSession; + + beforeEach(() => { + multiplexedSession = new MultiplexedSession(MOCK_DATABASE); + }); + + afterEach(() => { + if (multiplexedSession._refreshHandle) { + clearInterval(multiplexedSession._refreshHandle); + } + }); + + it('should schedule multiplexed session maintenance setInterval in ROOT_CONTEXT', () => { + const tracer = trace.getTracer('test'); + + const setIntervalStub = sandbox + .stub(global, 'setInterval') + .callsFake(() => { + const activeSpan = trace.getSpan(context.active()); + + // Assert that the active context is ROOT_CONTEXT (i.e., no active span) + assert.strictEqual( + activeSpan, + undefined, + 'setInterval scheduling must be isolated within ROOT_CONTEXT and not carry any active request span', + ); + return { + unref: () => {}, + } as unknown as NodeJS.Timeout; + }); + + // Start an active request context + tracer.startActiveSpan('request-span', span => { + try { + // Start maintenance under the request context + multiplexedSession._maintain(); + } finally { + span.end(); + } + }); + + // Verify that the refresh interval was scheduled + assert.strictEqual(setIntervalStub.callCount, 1); + }); + }); + + describe('MetricsTracerFactory background cleanup timer', () => { + afterEach(async () => { + await MetricsTracerFactory.resetInstance(); + }); + + it('should schedule MetricsTracerFactory cleanup setInterval in ROOT_CONTEXT', () => { + const tracer = trace.getTracer('test'); + + const setIntervalStub = sandbox + .stub(global, 'setInterval') + .callsFake(() => { + const activeSpan = trace.getSpan(context.active()); + + // Assert that the active context is ROOT_CONTEXT (i.e., no active span) + assert.strictEqual( + activeSpan, + undefined, + 'setInterval scheduling must be isolated within ROOT_CONTEXT and not carry any active request span', + ); + return { + unref: () => {}, + } as unknown as NodeJS.Timeout; + }); + + // Start an active request context + tracer.startActiveSpan('request-span', span => { + try { + // Instantiate the singleton under a request context + MetricsTracerFactory.getInstance('mock-project-id'); + } finally { + span.end(); + } + }); + + // Verify that the cleanup interval was scheduled + assert.strictEqual(setIntervalStub.callCount, 1); + }); + }); +}); diff --git a/handwritten/spanner/observability-test/spanner.ts b/handwritten/spanner/observability-test/spanner.ts index 555b14bda36e..d298c7d7dd3d 100644 --- a/handwritten/spanner/observability-test/spanner.ts +++ b/handwritten/spanner/observability-test/spanner.ts @@ -18,7 +18,6 @@ import * as assert from 'assert'; import {grpc} from 'google-gax'; import {google} from '../protos/protos'; import {Database, Instance, Spanner} from '../src'; -import {MultiplexedSession} from '../src/multiplexed-session'; import {MutationSet} from '../src/transaction'; import protobuf = google.spanner.v1; import v1 = google.spanner.v1; @@ -2025,49 +2024,4 @@ describe('End to end tracing headers', () => { txn.end(); } }); - - it('should schedule background timers in ROOT_CONTEXT to prevent trace/memory leaks', async () => { - const originalSetInterval = global.setInterval; - let capturedSchedulingContext: any; - - // Create an active user span context - const otelApi = require('@opentelemetry/api'); - const userTracer = otelApi.trace.getTracer('test-tracer'); - const userSpan = userTracer.startSpan('User.API.Request'); - - try { - await otelApi.context.with(otelApi.trace.setSpan(otelApi.ROOT_CONTEXT, userSpan), async () => { - // Monkey-patch setInterval to capture context exactly at scheduling time - (global as any).setInterval = function (callback: any, ms: any) { - capturedSchedulingContext = otelApi.context.active(); - // Return a mock handle - return { - unref: () => {}, - }; - }; - - // Trigger the MultiplexedSession _maintain() schedule method - // (Accessing private method for targeted unit verification) - const mux = new MultiplexedSession({} as any); - (mux as any)._maintain(); - }); - - // Verify that scheduling DID NOT capture the active user span context, - // but instead securely captured ROOT_CONTEXT - assert.strictEqual( - otelApi.trace.getSpan(capturedSchedulingContext), - undefined, - 'setInterval MUST be scheduled under ROOT_CONTEXT' - ); - assert.strictEqual( - capturedSchedulingContext, - otelApi.ROOT_CONTEXT, - 'setInterval context did not match ROOT_CONTEXT' - ); - } finally { - // Clean up global state - global.setInterval = originalSetInterval; - userSpan.end(); - } - }); }); diff --git a/handwritten/spanner/src/metrics/metrics-tracer-factory.ts b/handwritten/spanner/src/metrics/metrics-tracer-factory.ts index 1bca56bcb64f..34f9e658c959 100644 --- a/handwritten/spanner/src/metrics/metrics-tracer-factory.ts +++ b/handwritten/spanner/src/metrics/metrics-tracer-factory.ts @@ -16,7 +16,7 @@ import * as crypto from 'crypto'; import * as os from 'os'; import * as process from 'process'; import {MeterProvider, MetricReader} from '@opentelemetry/sdk-metrics'; -import {Counter, Histogram} from '@opentelemetry/api'; +import {Counter, Histogram, context, ROOT_CONTEXT} from '@opentelemetry/api'; import {detectResources, Resource} from '@opentelemetry/resources'; import {GcpDetectorSync} from '@google-cloud/opentelemetry-resource-util'; import * as Constants from './constants'; @@ -83,9 +83,11 @@ export class MetricsTracerFactory { ); // Start the Tracer cleanup task at an interval - this._intervalTracerCleanup = setInterval( - this._cleanMetricsTracers.bind(this), - Constants.TRACER_CLEANUP_INTERVAL_MS, + this._intervalTracerCleanup = context.with(ROOT_CONTEXT, () => + setInterval( + this._cleanMetricsTracers.bind(this), + Constants.TRACER_CLEANUP_INTERVAL_MS, + ), ); // unref the interval to prevent it from blocking app termination // in the event loop