Skip to content
Merged
201 changes: 201 additions & 0 deletions handwritten/spanner/observability-test/context-isolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/*!
* 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', () => {
beforeEach(async () => {
MetricsTracerFactory.enabled = true;
await MetricsTracerFactory.resetInstance();
});

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);
});
});
});
7 changes: 6 additions & 1 deletion handwritten/spanner/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
);
}
12 changes: 7 additions & 5 deletions handwritten/spanner/src/metrics/metrics-tracer-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -140,7 +142,7 @@ export class MetricsTracerFactory {
/**
* Resets the singleton instance of the MetricsTracerFactory.
*/
public static async resetInstance(projectId?: string) {
public static async resetInstance() {
clearInterval(MetricsTracerFactory._instance?._intervalTracerCleanup);
await MetricsTracerFactory._instance?.resetMeterProvider();
MetricsTracerFactory._instance = null;
Expand Down
9 changes: 6 additions & 3 deletions handwritten/spanner/src/multiplexed-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -177,9 +178,11 @@ export class MultiplexedSession
clearInterval(this._refreshHandle);
}
const refreshRate = this.refreshRate! * 24 * 60 * 60000;
this._refreshHandle = setInterval(async () => {
await 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
Expand Down
9 changes: 7 additions & 2 deletions handwritten/spanner/src/session-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = context.with(ROOT_CONTEXT, () =>
setInterval(() => this._evictIdleSessions(), evictRate),
);
this._evictHandle.unref();

const pingRate = this.options.keepAlive! * 60000;

this._pingHandle = setInterval(() => this._pingIdleSessions(), pingRate);
this._pingHandle = context.with(ROOT_CONTEXT, () =>
setInterval(() => this._pingIdleSessions(), pingRate),
);
this._pingHandle.unref();
}

Expand Down
24 changes: 20 additions & 4 deletions handwritten/spanner/system-test/spanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
});
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading