diff --git a/handwritten/spanner/google-cloud-spanner-executor/src/cloud-client-executor.ts b/handwritten/spanner/google-cloud-spanner-executor/src/cloud-client-executor.ts new file mode 100644 index 00000000000..572e8d0f68c --- /dev/null +++ b/handwritten/spanner/google-cloud-spanner-executor/src/cloud-client-executor.ts @@ -0,0 +1,254 @@ +/*! + * 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 { ServerDuplexStream, status } from '@grpc/grpc-js'; +import { Spanner } from '../../src'; +import { trace, context, Tracer } from '@opentelemetry/api'; +import * as protos from '../../protos/protos'; +import { CloudUtil } from './cloud-util'; +import { OutcomeSender, IExecutionFlowContext } from './cloud-executor'; +import spanner = protos.google.spanner; +import SpannerAsyncActionRequest = spanner.executor.v1.SpannerAsyncActionRequest; +import SpannerAsyncActionResponse = spanner.executor.v1.SpannerAsyncActionResponse; +import ISpannerAction = spanner.executor.v1.ISpannerAction; +import IAdminAction = spanner.executor.v1.IAdminAction; +import ICreateCloudInstanceAction = spanner.executor.v1.ICreateCloudInstanceAction; + +/** + * Context for a single stream connection. + */ +export class ExecutionFlowContext implements IExecutionFlowContext { + private call: ServerDuplexStream< + SpannerAsyncActionRequest, + SpannerAsyncActionResponse + >; + private dbPath: string = ''; + + constructor( + call: ServerDuplexStream< + SpannerAsyncActionRequest, + SpannerAsyncActionResponse + > + ) { + this.call = call; + } + + /** + * Sends a response back to the client. + */ + public onNext(response: SpannerAsyncActionResponse): void { + this.call.write(response); + } + + /** + * Sends an error back to the client. + */ + public onError(error: Error): void { + this.call.emit('error', error); + } + + /** + * Clean up resources associated with the context. + */ + public cleanup(): void { + console.log('Cleaning up ExecutionFlowContext'); + } + + /** + * Gets or sets the database path for the context. + */ + public getDatabasePath(path: string | null | undefined): string { + if (!path) { + return this.dbPath; + } + this.dbPath = path; + return path; + } +} +/** + * CloudClientExecutor handles the execution of Spanner actions requested via the executor proxy. +*/ +export class CloudClientExecutor { + private spanner: Spanner; + private tracer: Tracer; + private enableGrpcFaultInjector: boolean; + + constructor(enableGrpcFaultInjector: boolean) { + this.enableGrpcFaultInjector = enableGrpcFaultInjector; + const spannerOptions = CloudUtil.getSpannerOptions(); + this.spanner = new Spanner(spannerOptions); + this.tracer = trace.getTracer(CloudClientExecutor.name); + } + + /** + * Creates a new ExecutionFlowContext for a stream. + */ + public createExecutionFlowContext( + call: ServerDuplexStream< + SpannerAsyncActionRequest, + SpannerAsyncActionResponse + > + ): ExecutionFlowContext { + return new ExecutionFlowContext(call); + } + + /** + * Starts handling a SpannerAsyncActionRequest. + */ + public async startHandlingRequest( + req: SpannerAsyncActionRequest, + executionContext: ExecutionFlowContext + ): Promise<{ code: number; details: string }> { + const outcomeSender = new OutcomeSender(req.actionId!, executionContext); + + if (!req.action) { + return outcomeSender.finishWithError({ + code: status.INVALID_ARGUMENT, + message: 'Invalid request: No action present', + }); + } + + const action = req.action; + const dbPath = executionContext.getDatabasePath(action.databasePath); + + let useMultiplexedSession = false; + if (action.spannerOptions?.sessionPoolOptions) { + useMultiplexedSession = !!action.spannerOptions.sessionPoolOptions.useMultiplexed; + } + /** + * Executes the appropriate action based on the request. + */ + this.executeAction( + outcomeSender, + action, + dbPath, + useMultiplexedSession, + executionContext + ).catch(err => { + console.error('Unhandled exception in action execution:', err); + outcomeSender.finishWithError(err); + }); + + return { code: status.OK, details: '' }; + } + /** + * Executes the CreateCloudInstance action. + */ + private async executeAction( + outcomeSender: OutcomeSender, + action: ISpannerAction, + dbPath: string, + useMultiplexedSession: boolean, + executionContext: ExecutionFlowContext + ): Promise<{ code: number; details: string }> { + const actionType = Object.keys(action).find(k => (action as any)[k] !== undefined) || 'unknown'; + const span = this.tracer.startSpan(`performaction_${actionType}`); + + return context.with(trace.setSpan(context.active(), span), async () => { + try { + if (action.admin) { + return await this.executeAdminAction(action.admin, outcomeSender); + } + + return outcomeSender.finishWithError({ + code: status.UNIMPLEMENTED, + message: `Action ${actionType} not implemented yet`, + }); + } catch (e: any) { + span.recordException(e); + console.warn('Unexpected error:', e); + return outcomeSender.finishWithError({ + code: status.INVALID_ARGUMENT, + message: `Unexpected error: ${e.message}`, + }); + } finally { + span.end(); + } + }); + } + + private async executeAdminAction( + action: IAdminAction, + sender: OutcomeSender + ): Promise<{ code: number; details: string }> { + try { + if (action.createCloudInstance) { + return await this.executeCreateCloudInstance(action.createCloudInstance, sender); + } + return sender.finishWithError({ + code: status.UNIMPLEMENTED, + message: 'Admin action not implemented', + }); + } catch (e: any) { + return sender.finishWithError(e); + } + } + + private async executeCreateCloudInstance( + action: ICreateCloudInstanceAction, + sender: OutcomeSender + ): Promise<{ code: number; details: string }> { + try { + console.log(`Creating instance: \n${JSON.stringify(action, null, 2)}`); + + const instanceId = action.instanceId!; + const projectId = action.projectId!; + const configId = action.instanceConfigId!; + + const instanceAdminClient = this.spanner.getInstanceAdminClient(); + + const [operation] = await instanceAdminClient.createInstance({ + parent: instanceAdminClient.projectPath(projectId), + instanceId: instanceId, + instance: { + config: instanceAdminClient.instanceConfigPath(projectId, configId), + displayName: instanceId, + nodeCount: action.nodeCount || 1, + processingUnits: action.processingUnits, + labels: action.labels || {}, + }, + }); + + console.log('Waiting for instance creation operation to complete...'); + + await operation.promise(); + + console.log(`Instance ${instanceId} created successfully.`); + + return sender.finishWithOK(); + } catch (err: any) { + if (err.code === status.ALREADY_EXISTS) { + console.log('Instance already exists, returning OK.'); + return sender.finishWithOK(); + } + console.error('Failed to create instance:', err); + return sender.finishWithError(err); + } + } + /** + * Simulates an end-to-end trace verification task. + */ + public async getEndToEndTraceVerificationTask(traceId: string): Promise { + return new Promise(resolve => { + setTimeout(async () => { + console.log(`Verifying trace ${traceId}...`); + resolve(true); + }, 10000); + }); + } +} + + diff --git a/handwritten/spanner/google-cloud-spanner-executor/src/cloud-executor-impl.ts b/handwritten/spanner/google-cloud-spanner-executor/src/cloud-executor-impl.ts new file mode 100644 index 00000000000..3d1a868efbc --- /dev/null +++ b/handwritten/spanner/google-cloud-spanner-executor/src/cloud-executor-impl.ts @@ -0,0 +1,185 @@ +/*! + * 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 { ServerDuplexStream, status, ServiceError } from '@grpc/grpc-js'; +import { trace, context, Tracer } from '@opentelemetry/api'; +import { CloudClientExecutor } from './cloud-client-executor'; +import * as protos from '../../protos/protos'; +import spanner = protos.google.spanner; +import SpannerAsyncActionRequest = spanner.executor.v1.SpannerAsyncActionRequest; +import SpannerAsyncActionResponse = spanner.executor.v1.SpannerAsyncActionResponse; + +const MAX_CLOUD_TRACE_CHECK_LIMIT = 20; + +/** + * Implements the SpannerExecutorProxy service, which handles asynchronous + * Spanner actions via a bidirectional gRPC stream. + */ +export class CloudExecutorImpl { + private clientExecutor: CloudClientExecutor; + private multiplexedSessionOperationsRatio: number; + private cloudTraceCheckCount = 0; + private tracer: Tracer; + + constructor( + enableGrpcFaultInjector: boolean, + multiplexedSessionOperationsRatio: number, + ) { + this.clientExecutor = new CloudClientExecutor(enableGrpcFaultInjector); + this.multiplexedSessionOperationsRatio = multiplexedSessionOperationsRatio; + + this.tracer = trace.getTracer(CloudClientExecutor.name); + } + + /** + * Increments the counter for Cloud Trace checks. + */ + private incrementCloudTraceCheckCount(): void { + this.cloudTraceCheckCount++; + } + + /** + * Gets the current count of Cloud Trace checks. + */ + private getCloudTraceCheckCount(): number { + return this.cloudTraceCheckCount; + } + + /** + * Handles incoming SpannerAsyncActionRequest messages from the client. + */ + public executeActionAsync( + call: ServerDuplexStream< + SpannerAsyncActionRequest, + SpannerAsyncActionResponse + >, + ): void { + // Create a top-level OpenTelemetry span for streaming request. + const span = this.tracer.startSpan( + 'nodejs_systest_execute_actions_stream', + { + root: true, + }, + ); + + // Make the new span the active context for this stream process + context.with(trace.setSpan(context.active(), span), () => { + const traceId = span.spanContext().traceId; + const isSampled = ((span.spanContext().traceFlags ?? 0) & 1) === 1; + let requestHasReadOrQueryAction = false; + + // The executionContext manages the state and flow for this specific stream + const executionContext = + this.clientExecutor.createExecutionFlowContext(call); + + // Handle receiving requests on duplex stream + call.on('data', async (request: SpannerAsyncActionRequest) => { + console.log(`Receiving request: \n${JSON.stringify(request, null, 2)}`); + + // Use Multiplexed sessions for all supported operations if the + // multiplexedSessionOperationsRatio from command line is > 0.0 + if (this.multiplexedSessionOperationsRatio > 0.0) { + if (!request.action) request.action = {}; + if (!request.action.spannerOptions) + request.action.spannerOptions = {}; + if (!request.action.spannerOptions.sessionPoolOptions) + request.action.spannerOptions.sessionPoolOptions = {}; + + request.action.spannerOptions.sessionPoolOptions.useMultiplexed = + true; + + console.log( + `Updated request to set multiplexed session flag: \n${JSON.stringify(request, null, 2)}`, + ); + } + + // Check if the requested action is READ or QUERY + if (request.action?.read || request.action?.query) { + requestHasReadOrQueryAction = true; + } + + try { + const reqStatus = await this.clientExecutor.startHandlingRequest( + request, + executionContext, + ); + if (reqStatus.code !== status.OK) { + console.warn( + `Failed to handle request, half closed: ${reqStatus.details}`, + ); + } + } catch (err) { + console.warn('Exception when handling request', err); + } + }); + + // Handle stream errors + call.on('error', (err: Error) => { + console.warn('Client ends the stream with error.', err); + executionContext.cleanup(); + }); + + // Handle the completion of the client stream + call.on('end', async () => { + span.end(); + + if ( + isSampled && + this.getCloudTraceCheckCount() < MAX_CLOUD_TRACE_CHECK_LIMIT && + requestHasReadOrQueryAction + ) { + try { + console.log( + `Starting end to end trace verification for trace_id:${traceId}`, + ); + + const isValidTrace = + await this.clientExecutor.getEndToEndTraceVerificationTask( + traceId, + ); + this.incrementCloudTraceCheckCount(); + + if (!isValidTrace) { + const err = new Error( + `failed to verify end to end trace for trace_id: ${traceId}`, + ) as ServiceError; + err.code = status.INTERNAL; + + executionContext.onError(err); + executionContext.cleanup(); + return; + } + } catch (e: any) { + console.warn( + `Failed to verify end to end trace with exception: ${e.message}`, + e, + ); + executionContext.onError(e); + executionContext.cleanup(); + return; + } + } + + console.log('Client called Done, half closed'); + executionContext.cleanup(); + + call.end(); + }); + }); + } +} + + diff --git a/handwritten/spanner/google-cloud-spanner-executor/src/cloud-executor.ts b/handwritten/spanner/google-cloud-spanner-executor/src/cloud-executor.ts new file mode 100644 index 00000000000..963c198fbdd --- /dev/null +++ b/handwritten/spanner/google-cloud-spanner-executor/src/cloud-executor.ts @@ -0,0 +1,384 @@ +/*! + * 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 { status } from '@grpc/grpc-js'; +import * as protos from '../../protos/protos'; + +// Import Protobuf types +import spanner = protos.google.spanner; +import SpannerActionOutcome = spanner.executor.v1.SpannerActionOutcome; +import SpannerAsyncActionResponse = spanner.executor.v1.SpannerAsyncActionResponse; +import ITableMetadata = spanner.executor.v1.ITableMetadata; +import IColumnMetadata = spanner.executor.v1.IColumnMetadata; +import ValueList = spanner.executor.v1.ValueList; +import ChangeStreamRecord = spanner.executor.v1.ChangeStreamRecord; +import StructType = spanner.v1.StructType; +import IType = spanner.v1.IType; +import Timestamp = protos.google.protobuf.Timestamp; +import ReadResult = spanner.executor.v1.ReadResult; +import QueryResult = spanner.executor.v1.QueryResult; + +/** + * Defines the interface for sending responses back to the client for a specific + * gRPC stream. This is implemented by ExecutionFlowContext in + * cloud-client-executor.ts to avoid circular dependencies. + */ +export interface IExecutionFlowContext { + onNext(response: SpannerAsyncActionResponse): void; +} + +/** + * Manages metadata for tables and columns within a transaction, such as key + * column order and column types. + */ +export class Metadata { + private tableKeyColumnsInOrder: Map = new Map(); + private tableColumnsByName: Map> = new Map(); + + /** + * Initializes metadata from a list of table metadata provided at the start of a + * transaction. + * @param metadata An array of table metadata. + */ + constructor(metadata: ITableMetadata[] | null | undefined) { + if (metadata) { + for (const table of metadata) { + const tableName = table.name || ''; + this.tableKeyColumnsInOrder.set(tableName, table.keyColumn || []); + + const colMap = new Map(); + if (table.column) { + for (const col of table.column) { + if (col.name) { + colMap.set(col.name, col); + } + } + } + this.tableColumnsByName.set(tableName, colMap); + } + } + } + /** + * Retrieves the types of the key columns for a given table, in order. + * @param tableName The name of the table. + * @returns An array of key column types. + * @throws If metadata for the table does not exist. + */ + public getKeyColumnTypes(tableName: string): IType[] { + if (!this.tableKeyColumnsInOrder.has(tableName)) { + throw new Error(`There is no metadata for table: ${tableName}`); + } + const columns = this.tableKeyColumnsInOrder.get(tableName)!; + // Filter out undefined types if any + return columns.map(c => c.type!).filter(t => !!t); + } + + /** + * Retrieves the type of a specific column in a given table. + * @param tableName The name of the table. + * @param columnName The name of the column. + * @returns The type of the column. + * @throws If metadata for the table or column does not exist. + */ + public getColumnType(tableName: string, columnName: string): IType { + if (!this.tableColumnsByName.has(tableName)) { + throw new Error(`There is no metadata for table: ${tableName}`); + } + const colMap = this.tableColumnsByName.get(tableName)!; + if (!colMap.has(columnName)) { + throw new Error(`Metadata for table ${tableName} contains no column named ${columnName}`); + } + return colMap.get(columnName)!.type!; + } +} + +/** + * A utility class for sending action outcomes back to the client. It handles + * buffering for read actions and sends partial results in batches to manage + * stream flow. +*/ +export class OutcomeSender { + private actionId: number; + private context: IExecutionFlowContext; + + private timestamp: Timestamp | null = null; + private hasReadResult = false; + private hasQueryResult = false; + private hasChangeStreamRecords = false; + + private table: string | null = null; + private index: string | null = null; + private requestIndex: number | null = null; + private rowType: StructType | null = null; + + private readResultRows: ValueList[] = []; + private queryResultRows: ValueList[] = []; + private changeStreamRecords: ChangeStreamRecord[] = []; + private rowsModified: (number | string)[] = []; + + private rowCount = 0; + private changeStreamRecordCount = 0; + + private changeStreamForQuery = ""; + private partitionTokenForQuery = ""; + private changeStreamRecordReceivedTimestamp = 0; + private changeStreamHeartbeatMilliseconds = 0; + private isPartitionedChangeStreamQuery = false; + + private static readonly MAX_ROWS_PER_BATCH = 100; + private static readonly MAX_CHANGE_STREAM_RECORDS_PER_BATCH = 2000; + /** + * @param actionId The ID of the action this sender is responsible for. + * @param context The execution context for sending responses. + */ + constructor(actionId: number, context: IExecutionFlowContext) { + this.actionId = actionId; + this.context = context; + this.timestamp = Timestamp.create({ seconds: 0, nanos: 0 }); + } + /** Sets the commit timestamp for the outcome. */ + public setTimestamp(timestamp: Timestamp) { + this.timestamp = timestamp; + } + /** Sets the row type for the outcome. */ + public setRowType(rowType: StructType) { + this.rowType = rowType; + } + /** Initializes the sender for a standard read operation. */ + public initForRead(table: string, index?: string | null) { + this.hasReadResult = true; + this.table = table; + if (index) this.index = index; + } + /** Initializes the sender for a query operation. */ + public initForQuery() { + this.hasQueryResult = true; + } + /** Initializes the sender for a batch read operation. */ + public initForBatchRead(table: string, index?: string | null) { + this.initForRead(table, index); + this.requestIndex = 0; + } + /** + * Initializes the sender for a change stream query. + * @param heartbeatMillis The heartbeat interval in milliseconds. + * @param name The name of the change stream. + * @param partitionToken The partition token, if any. + */ + public initForChangeStreamQuery( + heartbeatMillis: number, + name: string, + partitionToken?: string | null + ) { + this.hasChangeStreamRecords = true; + this.changeStreamRecordReceivedTimestamp = 0; + this.changeStreamHeartbeatMilliseconds = heartbeatMillis; + this.changeStreamForQuery = name; + if (partitionToken) { + this.isPartitionedChangeStreamQuery = true; + this.partitionTokenForQuery = partitionToken; + } + } + /** Updates the timestamp of the last received change stream record. */ + public updateChangeStreamRecordReceivedTimestamp(ts: number) { + this.changeStreamRecordReceivedTimestamp = ts; + } + /** Gets the timestamp of the last received change stream record. */ + public getChangeStreamRecordReceivedTimestamp() { + return this.changeStreamRecordReceivedTimestamp; + } + /** Gets the configured heartbeat interval for the change stream query. */ + public getChangeStreamHeartbeatMilliSeconds() { + return this.changeStreamHeartbeatMilliseconds; + } + /** Returns true if the change stream query is partitioned. */ + public getIsPartitionedChangeStreamQuery() { + return this.isPartitionedChangeStreamQuery; + } + /** Appends the number of rows modified in a DML operation. */ + public appendRowsModifiedInDml(rows: number | string) { + this.rowsModified.push(rows); + } + /** Finishes the outcome with final OK status. */ + public finishWithOK(): { code: number; details: string } { + this.flush(); + const outcome = SpannerActionOutcome.create({ + status: CloudExecutor.toProto(status.OK), + commitTime: this.timestamp + }); + return this.sendOutcome(outcome); + } + /** Finishes the outcome with a transaction restarted status. */ + public finishWithTransactionRestarted(): { code: number; details: string } { + this.flush(); + const outcome = SpannerActionOutcome.create({ + status: CloudExecutor.toProto(status.OK), + transactionRestarted: true, + commitTime: this.timestamp + }); + return this.sendOutcome(outcome); + } + /** Finishes the outcome with an error status. */ + public finishWithError(err: any): { code: number; details: string } { + this.flush(); + const s = CloudExecutor.toStatus(err); + const outcome = SpannerActionOutcome.create({ + status: CloudExecutor.toProto(s.code, s.message) + }); + return this.sendOutcome(outcome); + } + /** + * Appends a row to the appropriate buffer (read or query). Flushes the buffer + * if it reaches the maximum batch size. + */ + public appendRow(row: ValueList): { code: number; details: string } { + if (!this.hasReadResult && !this.hasQueryResult) { + return { code: status.INVALID_ARGUMENT, details: "Either hasReadResult or hasQueryResult should be true" }; + } + if (!this.rowType) { + return { code: status.INVALID_ARGUMENT, details: "RowType should be set first" }; + } + + if (this.hasReadResult) { + this.readResultRows.push(row); + } else { + this.queryResultRows.push(row); + } + this.rowCount++; + + if (this.rowCount >= OutcomeSender.MAX_ROWS_PER_BATCH) { + this.flush(); + } + return { code: status.OK, details: "" }; + } + /** + * Appends a change stream record to the buffer. Flushes the buffer if it + * reaches the maximum batch size. + */ + public appendChangeStreamRecord(record: ChangeStreamRecord): { code: number; details: string } { + if (!this.hasChangeStreamRecords) { + return { code: status.INVALID_ARGUMENT, details: "hasChangeStreamRecords should be true" }; + } + this.changeStreamRecords.push(record); + this.changeStreamRecordCount++; + + if (this.changeStreamRecordCount >= OutcomeSender.MAX_CHANGE_STREAM_RECORDS_PER_BATCH) { + this.flush(); + } + return { code: status.OK, details: "" }; + } + /** + * Flushes any buffered data (rows, change stream records, or DML rows modified) + * to the execution context. + */ + private flush() { + if (this.rowCount === 0 && this.changeStreamRecordCount === 0 && this.rowsModified.length === 0) { + return; + } + + const outcome: any = {}; + + if (this.timestamp) { + outcome.commitTime = this.timestamp; + } + + if (this.rowsModified.length > 0) { + outcome.dmlRowsModified = this.rowsModified; + } + + if (this.hasReadResult) { + const readResult = ReadResult.create({ + table: this.table || undefined, + index: this.index || undefined, + requestIndex: this.requestIndex || undefined, + rowType: this.rowType || undefined, + row: this.readResultRows + }); + outcome.readResult = readResult; + } else if (this.hasQueryResult) { + const queryResult = QueryResult.create({ + rowType: this.rowType || undefined, + row: this.queryResultRows + }); + outcome.queryResult = queryResult; + } else if (this.hasChangeStreamRecords) { + outcome.changeStreamRecords = this.changeStreamRecords; + } + + this.sendOutcome(SpannerActionOutcome.create(outcome)); + this.readResultRows = []; + this.queryResultRows = []; + this.changeStreamRecords = []; + this.rowsModified = []; + this.rowCount = 0; + this.changeStreamRecordCount = 0; + } + /** + * Constructs and sends a SpannerAsyncActionResponse to the client. + * @param outcome The outcome payload to send. + * @returns A status object indicating the result of the send operation. + */ + public sendOutcome(outcome: SpannerActionOutcome): { code: number; details: string } { + try { + const response = SpannerAsyncActionResponse.create({ + actionId: this.actionId, + outcome: outcome + }); + this.context.onNext(response); + return { code: status.OK, details: "" }; + } catch (e: any) { + console.error("Failed to send outcome", e); + return { code: status.INTERNAL, details: e.message }; + } + } +} + +/** + * A utility class providing static helper methods for the Cloud Spanner executor. +*/ +export class CloudExecutor { + public static readonly PROJECT_ID = 'spanner-cloud-systest'; + + /** + * Maps an error object to a gRPC status code and message. + */ + public static toStatus(err: any): { code: number; message: string } { + let code = status.UNKNOWN; + let message = err.message || "Unknown error"; + + if (err.code !== undefined && typeof err.code === 'number') { + code = err.code; + } + + return { code, message }; + } + + /** Converts a gRPC status code and message into a protobuf Status object. */ + public static toProto(code: number, message?: string): protos.google.rpc.IStatus { + return { + code: code, + message: message || "" + }; + } + + /** Converts a timestamp in microseconds to a query-friendly string format. */ + public static timestampToString(useNanosPrecision: boolean, timestampInMicros: number): string { + const date = new Date(timestampInMicros / 1000); + return `"${date.toISOString()}"`; + } +} + + diff --git a/handwritten/spanner/google-cloud-spanner-executor/src/cloud-util.ts b/handwritten/spanner/google-cloud-spanner-executor/src/cloud-util.ts new file mode 100644 index 00000000000..c4ca4246ed7 --- /dev/null +++ b/handwritten/spanner/google-cloud-spanner-executor/src/cloud-util.ts @@ -0,0 +1,108 @@ +/*! + * 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 fs from 'fs'; +import * as grpc from '@grpc/grpc-js'; +import { WorkerProxy } from './worker-proxy'; + +/** + * Provides utility methods for configuring the Cloud Spanner client for tests. +*/ +export class CloudUtil { + // If this is set too low, the peer server may return RESOURCE_EXHAUSTED errors if the response + // error message causes the trailing headers to exceed this limit. + private static readonly GRPC_MAX_HEADER_LIST_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB + + private static readonly TEST_HOST_IN_CERT = 'test-cert-2'; + + /** + * Creates the configuration object for the Spanner client for connecting to a + * test GFE, including gRPC channel setup. + */ + public static getSpannerOptions(): any { + const options: any = { + projectId: WorkerProxy.PROJECT_ID, + servicePath: 'localhost', + port: WorkerProxy.spannerPort, + defaultProjectId: WorkerProxy.PROJECT_ID, + libName: 'gccl', + libVersion: '9.9.9', + }; + + const maxMessageSize = 100 * 1024 * 1024; + + const grpcOptions: grpc.ClientOptions = { + 'grpc.max_receive_message_length': maxMessageSize, + 'grpc.max_metadata_size': CloudUtil.GRPC_MAX_HEADER_LIST_SIZE_BYTES, + }; + + if (WorkerProxy.usePlainTextChannel) { + options.sslCreds = grpc.credentials.createInsecure(); + } else { + const rootCerts = CloudUtil.CertUtil.copyCert(WorkerProxy.cert); + options.sslCreds = grpc.credentials.createSsl(rootCerts); + + // Override authority to match the test certificate. + // In Node.js gRPC: + // - ssl_target_name_override is used for the SSL handshake check (CN/SAN matching). + // - default_authority is used for the HTTP/2 :authority header. + (grpcOptions as any)['grpc.ssl_target_name_override'] = CloudUtil.TEST_HOST_IN_CERT; + (grpcOptions as any)['grpc.default_authority'] = CloudUtil.TEST_HOST_IN_CERT; + } + + options.grpcOptions = grpcOptions; + + return options; + } + + /** + * A utility class for handling certificates. + */ + public static CertUtil = class { + /** + * Copies cert resource to a buffer, stripping out content outside BEGIN/END blocks. + */ + public static copyCert(certPath: string): Buffer { + try { + const certContent = fs.readFileSync(certPath, 'utf8'); + const lines = certContent.split(/\r?\n/); + let cleanCert = ''; + let inCert = false; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine === '-----BEGIN CERTIFICATE-----') { + inCert = true; + } + + if (inCert) { + cleanCert += line + '\n'; + } + + if (trimmedLine === '-----END CERTIFICATE-----') { + inCert = false; + } + } + return Buffer.from(cleanCert); + } catch (e) { + throw new Error(`Failed to read certificate from ${certPath}: ${e}`); + } + } + }; +} + + diff --git a/handwritten/spanner/google-cloud-spanner-executor/src/worker-proxy.ts b/handwritten/spanner/google-cloud-spanner-executor/src/worker-proxy.ts new file mode 100644 index 00000000000..9d3ae37a8e4 --- /dev/null +++ b/handwritten/spanner/google-cloud-spanner-executor/src/worker-proxy.ts @@ -0,0 +1,318 @@ +/*! + * 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 grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; +import yargs from 'yargs'; +import * as path from 'path'; +import * as fs from 'fs'; +import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node'; +import {TraceExporter} from '@google-cloud/opentelemetry-cloud-trace-exporter'; +import {Resource} from '@opentelemetry/resources'; +import {ATTR_SERVICE_NAME} from '@opentelemetry/semantic-conventions'; +import { + BatchSpanProcessor, + TraceIdRatioBasedSampler, +} from '@opentelemetry/sdk-trace-base'; +import {CloudExecutorImpl} from './cloud-executor-impl'; +import {HealthImplementation} from 'grpc-health-check'; +import {ReflectionService} from '@grpc/reflection'; + +const PROTO_PATH = path.join( + __dirname, + '../../protos/google/spanner/executor/v1/cloud_executor.proto', +); + +const OPTION_SPANNER_PORT = 'spanner_port'; +const OPTION_PROXY_PORT = 'proxy_port'; +const OPTION_CERTIFICATE = 'cert'; +const OPTION_SERVICE_KEY_FILE = 'service_key_file'; +const OPTION_USE_PLAIN_TEXT_CHANNEL = 'use_plain_text_channel'; +const OPTION_ENABLE_GRPC_FAULT_INJECTOR = 'enable_grpc_fault_injector'; +const OPTION_MULTIPLEXED_SESSION_OPERATIONS_RATIO = + 'multiplexed_session_operations_ratio'; + +/** + * class WorkerProxy which acts as a proxy server that forwards requests to the Spanner executor + */ +export class WorkerProxy { + public static spannerPort = 0; + public static proxyPort = 0; + public static cert = ''; + public static serviceKeyFile = ''; + public static multiplexedSessionOperationsRatio = 0.0; + public static usePlainTextChannel = false; + public static enableGrpcFaultInjector = false; + public static openTelemetrySdk: NodeTracerProvider; + + public static readonly PROJECT_ID = 'spanner-cloud-systest'; + public static readonly CLOUD_TRACE_ENDPOINT = + 'staging-cloudtrace.sandbox.googleapis.com:443'; + + private static readonly MIN_PORT = 0; + private static readonly MAX_PORT = 65535; + private static readonly MIN_RATIO = 0.0; + private static readonly MAX_RATIO = 1.0; + private static readonly TRACE_SAMPLING_RATE = 0.01; + + /** + * Sets up the OpenTelemetry SDK + */ + public static async setupOpenTelemetrySdk(): Promise { + const exporterConfig: any = { + projectId: WorkerProxy.PROJECT_ID, + apiEndpoint: WorkerProxy.CLOUD_TRACE_ENDPOINT, + }; + + if (WorkerProxy.serviceKeyFile) { + exporterConfig.keyFilename = WorkerProxy.serviceKeyFile; + } + + const traceExporter = new TraceExporter(exporterConfig); + + const provider = new NodeTracerProvider({ + resource: new Resource({ + [ATTR_SERVICE_NAME]: 'spanner-node-worker-proxy', + }) as any, + sampler: new TraceIdRatioBasedSampler(WorkerProxy.TRACE_SAMPLING_RATE), + spanProcessors: [new BatchSpanProcessor(traceExporter as any)], + }); + + provider.register(); + return provider; + } + + /** + * Builds the command line options for the worker proxy + */ + public static buildOptions(args: string[]): any { + const parser = yargs(args); + + parser.option(OPTION_SPANNER_PORT, { + type: 'number', + description: 'Port of Spanner Frontend to which to send requests.', + }); + parser.option(OPTION_PROXY_PORT, { + type: 'number', + description: 'Proxy port to start worker proxy on.', + }); + parser.option(OPTION_CERTIFICATE, { + type: 'string', + description: 'Certificate used to connect to Spanner GFE.', + }); + parser.option(OPTION_SERVICE_KEY_FILE, { + type: 'string', + description: 'Service key file used to set authentication.', + }); + parser.option(OPTION_USE_PLAIN_TEXT_CHANNEL, { + type: 'boolean', + description: + 'Use a plain text gRPC channel (intended for the Cloud Spanner Emulator).', + }); + parser.option(OPTION_ENABLE_GRPC_FAULT_INJECTOR, { + type: 'boolean', + description: 'Enable grpc fault injector in cloud client executor.', + }); + parser.option(OPTION_MULTIPLEXED_SESSION_OPERATIONS_RATIO, { + type: 'number', + description: 'Ratio of operations to use multiplexed sessions.', + }); + + try { + return parser.parseSync(); + } catch (e: any) { + throw new Error(e.message); + } + } + + /** + * main method to spin up the server and start the worker proxy + */ + public static async main(args: string[]) { + const commandLineString = WorkerProxy.buildOptions(args); + const commandLine = commandLineString as any; + + if (commandLine[OPTION_SPANNER_PORT] === undefined) { + throw new Error( + 'Spanner proxyPort need to be assigned in order to start worker proxy.', + ); + } + WorkerProxy.spannerPort = commandLine[OPTION_SPANNER_PORT]; + if ( + WorkerProxy.spannerPort < WorkerProxy.MIN_PORT || + WorkerProxy.spannerPort > WorkerProxy.MAX_PORT + ) { + throw new Error( + 'Spanner proxyPort must be between ' + + WorkerProxy.MIN_PORT + + ' and ' + + WorkerProxy.MAX_PORT, + ); + } + + if (commandLine[OPTION_PROXY_PORT] === undefined) { + throw new Error( + 'Proxy port need to be assigned in order to start worker proxy.', + ); + } + WorkerProxy.proxyPort = commandLine[OPTION_PROXY_PORT]; + if ( + WorkerProxy.proxyPort < WorkerProxy.MIN_PORT || + WorkerProxy.proxyPort > WorkerProxy.MAX_PORT + ) { + throw new Error( + 'Proxy port must be between ' + + WorkerProxy.MIN_PORT + + ' and ' + + WorkerProxy.MAX_PORT, + ); + } + + if (!commandLine[OPTION_CERTIFICATE]) { + throw new Error( + 'Certificate need to be assigned in order to start worker proxy.', + ); + } + WorkerProxy.cert = commandLine[OPTION_CERTIFICATE]; + + if (commandLine[OPTION_SERVICE_KEY_FILE]) { + WorkerProxy.serviceKeyFile = commandLine[OPTION_SERVICE_KEY_FILE]; + } + + WorkerProxy.usePlainTextChannel = + !!commandLine[OPTION_USE_PLAIN_TEXT_CHANNEL]; + WorkerProxy.enableGrpcFaultInjector = + !!commandLine[OPTION_ENABLE_GRPC_FAULT_INJECTOR]; + + if ( + commandLine[OPTION_MULTIPLEXED_SESSION_OPERATIONS_RATIO] !== undefined + ) { + WorkerProxy.multiplexedSessionOperationsRatio = Number( + commandLine[OPTION_MULTIPLEXED_SESSION_OPERATIONS_RATIO], + ); + console.info( + `Multiplexed session ratio from commandline arg: \n${WorkerProxy.multiplexedSessionOperationsRatio}`, + ); + if ( + WorkerProxy.multiplexedSessionOperationsRatio < WorkerProxy.MIN_RATIO || + WorkerProxy.multiplexedSessionOperationsRatio > WorkerProxy.MAX_RATIO + ) { + throw new Error( + 'Spanner multiplexedSessionOperationsRatio must be between ' + + WorkerProxy.MIN_RATIO + + ' and ' + + WorkerProxy.MAX_RATIO, + ); + } + } + + // Setup the OpenTelemetry for tracing + WorkerProxy.openTelemetrySdk = await WorkerProxy.setupOpenTelemetrySdk(); + + // Check if proto file exists + if (!fs.existsSync(PROTO_PATH)) { + throw new Error(`Proto file not found at ${PROTO_PATH}`); + } + + const packageDefinition = protoLoader.loadSync(PROTO_PATH, { + keepCase: false, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [ + path.join(__dirname, '../../protos'), + path.join(__dirname, '../../../node_modules/google-proto-files'), + path.join(__dirname, '../../../node_modules/google-gax/build/protos'), + ], + }); + const protoDescriptor = grpc.loadPackageDefinition( + packageDefinition, + ) as any; + const spannerExecutorProxy = + protoDescriptor.google.spanner.executor.v1.SpannerExecutorProxy; + + let server: grpc.Server; + for (;;) { + try { + const cloudExecutorImpl = new CloudExecutorImpl( + WorkerProxy.enableGrpcFaultInjector, + WorkerProxy.multiplexedSessionOperationsRatio, + ); + + server = new grpc.Server(); + server.addService( + spannerExecutorProxy.service, + cloudExecutorImpl as any, + ); + + const healthImpl = new HealthImplementation({ + '': 'SERVING', + }); + healthImpl.addToServer(server); + const reflection = new ReflectionService(packageDefinition); + reflection.addToServer(server); + + const bindAddr = `0.0.0.0:${WorkerProxy.proxyPort}`; + const port = await new Promise((resolve, reject) => { + server.bindAsync( + bindAddr, + grpc.ServerCredentials.createInsecure(), + (err, port) => { + if (err) { + return reject(err); + } + resolve(port); + }, + ); + }); + + console.info(`Server started on proxyPort: ${port}`); + break; + } catch (e) { + console.warn( + `Failed to start server on proxyPort ${WorkerProxy.proxyPort}`, + e, + ); + // Wait briefly before retrying to avoid tight loop + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + const shutdown = () => { + // eslint-disable-next-line n/no-process-exit + setTimeout(() => process.exit(1), 2000).unref(); + server.tryShutdown(() => { + WorkerProxy.openTelemetrySdk + .shutdown() + .then(() => console.info('Tracing terminated')) + .catch(error => console.error('Error terminating tracing', error)) + // eslint-disable-next-line n/no-process-exit + .finally(() => process.exit(0)); + }); + }; + + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + } +} + +if (require.main === module) { + WorkerProxy.main(process.argv.slice(2)).catch(err => { + console.error('Failed to start worker proxy:', err); + throw err; + }); +} diff --git a/handwritten/spanner/package.json b/handwritten/spanner/package.json index eb3cac0449f..7f7a8af66f5 100644 --- a/handwritten/spanner/package.json +++ b/handwritten/spanner/package.json @@ -98,6 +98,8 @@ "uuid": "^11.1.0" }, "devDependencies": { + "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", + "@grpc/reflection": "^1.0.4", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@types/concat-stream": "^2.0.3", @@ -112,6 +114,7 @@ "@types/request": "^2.48.12", "@types/sinon": "^21.0.0", "@types/through2": "^2.0.41", + "@types/yargs": "^17.0.35", "binary-search-bounds": "^2.0.5", "c8": "^10.1.3", "codecov": "^3.8.3", @@ -119,6 +122,7 @@ "dedent": "^1.5.3", "execa": "^5.0.0", "gapic-tools": "^1.0.1", + "grpc-health-check": "^2.1.0", "gts": "^6.0.2", "jsdoc": "^4.0.4", "jsdoc-fresh": "^5.0.0", @@ -140,5 +144,8 @@ "typescript": "^5.8.2", "yargs": "^17.7.2" }, + "overrides": { + "@opentelemetry/resources": "^1.8.0" + }, "homepage": "https://github.com/googleapis/google-cloud-node/tree/main/handwritten/spanner" -} +} \ No newline at end of file diff --git a/handwritten/spanner/protos/protos.d.ts b/handwritten/spanner/protos/protos.d.ts index 2beea4b0984..9bc6110e43e 100644 --- a/handwritten/spanner/protos/protos.d.ts +++ b/handwritten/spanner/protos/protos.d.ts @@ -5655,6 +5655,24 @@ export namespace google { /** Violation description */ description?: (string|null); + + /** Violation apiService */ + apiService?: (string|null); + + /** Violation quotaMetric */ + quotaMetric?: (string|null); + + /** Violation quotaId */ + quotaId?: (string|null); + + /** Violation quotaDimensions */ + quotaDimensions?: ({ [k: string]: string }|null); + + /** Violation quotaValue */ + quotaValue?: (number|Long|string|null); + + /** Violation futureQuotaValue */ + futureQuotaValue?: (number|Long|string|null); } /** Represents a Violation. */ @@ -5672,6 +5690,24 @@ export namespace google { /** Violation description. */ public description: string; + /** Violation apiService. */ + public apiService: string; + + /** Violation quotaMetric. */ + public quotaMetric: string; + + /** Violation quotaId. */ + public quotaId: string; + + /** Violation quotaDimensions. */ + public quotaDimensions: { [k: string]: string }; + + /** Violation quotaValue. */ + public quotaValue: (number|Long|string); + + /** Violation futureQuotaValue. */ + public futureQuotaValue?: (number|Long|string|null); + /** * Creates a new Violation instance using the specified properties. * @param [properties] Properties to set @@ -6067,6 +6103,12 @@ export namespace google { /** FieldViolation description */ description?: (string|null); + + /** FieldViolation reason */ + reason?: (string|null); + + /** FieldViolation localizedMessage */ + localizedMessage?: (google.rpc.ILocalizedMessage|null); } /** Represents a FieldViolation. */ @@ -6084,6 +6126,12 @@ export namespace google { /** FieldViolation description. */ public description: string; + /** FieldViolation reason. */ + public reason: string; + + /** FieldViolation localizedMessage. */ + public localizedMessage?: (google.rpc.ILocalizedMessage|null); + /** * Creates a new FieldViolation instance using the specified properties. * @param [properties] Properties to set diff --git a/handwritten/spanner/protos/protos.js b/handwritten/spanner/protos/protos.js index 837290eefd8..9d0cbed8413 100644 --- a/handwritten/spanner/protos/protos.js +++ b/handwritten/spanner/protos/protos.js @@ -16194,6 +16194,12 @@ * @interface IViolation * @property {string|null} [subject] Violation subject * @property {string|null} [description] Violation description + * @property {string|null} [apiService] Violation apiService + * @property {string|null} [quotaMetric] Violation quotaMetric + * @property {string|null} [quotaId] Violation quotaId + * @property {Object.|null} [quotaDimensions] Violation quotaDimensions + * @property {number|Long|null} [quotaValue] Violation quotaValue + * @property {number|Long|null} [futureQuotaValue] Violation futureQuotaValue */ /** @@ -16205,6 +16211,7 @@ * @param {google.rpc.QuotaFailure.IViolation=} [properties] Properties to set */ function Violation(properties) { + this.quotaDimensions = {}; if (properties) for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) if (properties[keys[i]] != null) @@ -16227,6 +16234,63 @@ */ Violation.prototype.description = ""; + /** + * Violation apiService. + * @member {string} apiService + * @memberof google.rpc.QuotaFailure.Violation + * @instance + */ + Violation.prototype.apiService = ""; + + /** + * Violation quotaMetric. + * @member {string} quotaMetric + * @memberof google.rpc.QuotaFailure.Violation + * @instance + */ + Violation.prototype.quotaMetric = ""; + + /** + * Violation quotaId. + * @member {string} quotaId + * @memberof google.rpc.QuotaFailure.Violation + * @instance + */ + Violation.prototype.quotaId = ""; + + /** + * Violation quotaDimensions. + * @member {Object.} quotaDimensions + * @memberof google.rpc.QuotaFailure.Violation + * @instance + */ + Violation.prototype.quotaDimensions = $util.emptyObject; + + /** + * Violation quotaValue. + * @member {number|Long} quotaValue + * @memberof google.rpc.QuotaFailure.Violation + * @instance + */ + Violation.prototype.quotaValue = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + + /** + * Violation futureQuotaValue. + * @member {number|Long|null|undefined} futureQuotaValue + * @memberof google.rpc.QuotaFailure.Violation + * @instance + */ + Violation.prototype.futureQuotaValue = null; + + // OneOf field names bound to virtual getters and setters + var $oneOfFields; + + // Virtual OneOf for proto3 optional field + Object.defineProperty(Violation.prototype, "_futureQuotaValue", { + get: $util.oneOfGetter($oneOfFields = ["futureQuotaValue"]), + set: $util.oneOfSetter($oneOfFields) + }); + /** * Creates a new Violation instance using the specified properties. * @function create @@ -16255,6 +16319,19 @@ writer.uint32(/* id 1, wireType 2 =*/10).string(message.subject); if (message.description != null && Object.hasOwnProperty.call(message, "description")) writer.uint32(/* id 2, wireType 2 =*/18).string(message.description); + if (message.apiService != null && Object.hasOwnProperty.call(message, "apiService")) + writer.uint32(/* id 3, wireType 2 =*/26).string(message.apiService); + if (message.quotaMetric != null && Object.hasOwnProperty.call(message, "quotaMetric")) + writer.uint32(/* id 4, wireType 2 =*/34).string(message.quotaMetric); + if (message.quotaId != null && Object.hasOwnProperty.call(message, "quotaId")) + writer.uint32(/* id 5, wireType 2 =*/42).string(message.quotaId); + if (message.quotaDimensions != null && Object.hasOwnProperty.call(message, "quotaDimensions")) + for (var keys = Object.keys(message.quotaDimensions), i = 0; i < keys.length; ++i) + writer.uint32(/* id 6, wireType 2 =*/50).fork().uint32(/* id 1, wireType 2 =*/10).string(keys[i]).uint32(/* id 2, wireType 2 =*/18).string(message.quotaDimensions[keys[i]]).ldelim(); + if (message.quotaValue != null && Object.hasOwnProperty.call(message, "quotaValue")) + writer.uint32(/* id 7, wireType 0 =*/56).int64(message.quotaValue); + if (message.futureQuotaValue != null && Object.hasOwnProperty.call(message, "futureQuotaValue")) + writer.uint32(/* id 8, wireType 0 =*/64).int64(message.futureQuotaValue); return writer; }; @@ -16285,7 +16362,7 @@ Violation.decode = function decode(reader, length, error) { if (!(reader instanceof $Reader)) reader = $Reader.create(reader); - var end = length === undefined ? reader.len : reader.pos + length, message = new $root.google.rpc.QuotaFailure.Violation(); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.google.rpc.QuotaFailure.Violation(), key, value; while (reader.pos < end) { var tag = reader.uint32(); if (tag === error) @@ -16299,6 +16376,49 @@ message.description = reader.string(); break; } + case 3: { + message.apiService = reader.string(); + break; + } + case 4: { + message.quotaMetric = reader.string(); + break; + } + case 5: { + message.quotaId = reader.string(); + break; + } + case 6: { + if (message.quotaDimensions === $util.emptyObject) + message.quotaDimensions = {}; + var end2 = reader.uint32() + reader.pos; + key = ""; + value = ""; + while (reader.pos < end2) { + var tag2 = reader.uint32(); + switch (tag2 >>> 3) { + case 1: + key = reader.string(); + break; + case 2: + value = reader.string(); + break; + default: + reader.skipType(tag2 & 7); + break; + } + } + message.quotaDimensions[key] = value; + break; + } + case 7: { + message.quotaValue = reader.int64(); + break; + } + case 8: { + message.futureQuotaValue = reader.int64(); + break; + } default: reader.skipType(tag & 7); break; @@ -16334,12 +16454,38 @@ Violation.verify = function verify(message) { if (typeof message !== "object" || message === null) return "object expected"; + var properties = {}; if (message.subject != null && message.hasOwnProperty("subject")) if (!$util.isString(message.subject)) return "subject: string expected"; if (message.description != null && message.hasOwnProperty("description")) if (!$util.isString(message.description)) return "description: string expected"; + if (message.apiService != null && message.hasOwnProperty("apiService")) + if (!$util.isString(message.apiService)) + return "apiService: string expected"; + if (message.quotaMetric != null && message.hasOwnProperty("quotaMetric")) + if (!$util.isString(message.quotaMetric)) + return "quotaMetric: string expected"; + if (message.quotaId != null && message.hasOwnProperty("quotaId")) + if (!$util.isString(message.quotaId)) + return "quotaId: string expected"; + if (message.quotaDimensions != null && message.hasOwnProperty("quotaDimensions")) { + if (!$util.isObject(message.quotaDimensions)) + return "quotaDimensions: object expected"; + var key = Object.keys(message.quotaDimensions); + for (var i = 0; i < key.length; ++i) + if (!$util.isString(message.quotaDimensions[key[i]])) + return "quotaDimensions: string{k:string} expected"; + } + if (message.quotaValue != null && message.hasOwnProperty("quotaValue")) + if (!$util.isInteger(message.quotaValue) && !(message.quotaValue && $util.isInteger(message.quotaValue.low) && $util.isInteger(message.quotaValue.high))) + return "quotaValue: integer|Long expected"; + if (message.futureQuotaValue != null && message.hasOwnProperty("futureQuotaValue")) { + properties._futureQuotaValue = 1; + if (!$util.isInteger(message.futureQuotaValue) && !(message.futureQuotaValue && $util.isInteger(message.futureQuotaValue.low) && $util.isInteger(message.futureQuotaValue.high))) + return "futureQuotaValue: integer|Long expected"; + } return null; }; @@ -16359,6 +16505,37 @@ message.subject = String(object.subject); if (object.description != null) message.description = String(object.description); + if (object.apiService != null) + message.apiService = String(object.apiService); + if (object.quotaMetric != null) + message.quotaMetric = String(object.quotaMetric); + if (object.quotaId != null) + message.quotaId = String(object.quotaId); + if (object.quotaDimensions) { + if (typeof object.quotaDimensions !== "object") + throw TypeError(".google.rpc.QuotaFailure.Violation.quotaDimensions: object expected"); + message.quotaDimensions = {}; + for (var keys = Object.keys(object.quotaDimensions), i = 0; i < keys.length; ++i) + message.quotaDimensions[keys[i]] = String(object.quotaDimensions[keys[i]]); + } + if (object.quotaValue != null) + if ($util.Long) + (message.quotaValue = $util.Long.fromValue(object.quotaValue)).unsigned = false; + else if (typeof object.quotaValue === "string") + message.quotaValue = parseInt(object.quotaValue, 10); + else if (typeof object.quotaValue === "number") + message.quotaValue = object.quotaValue; + else if (typeof object.quotaValue === "object") + message.quotaValue = new $util.LongBits(object.quotaValue.low >>> 0, object.quotaValue.high >>> 0).toNumber(); + if (object.futureQuotaValue != null) + if ($util.Long) + (message.futureQuotaValue = $util.Long.fromValue(object.futureQuotaValue)).unsigned = false; + else if (typeof object.futureQuotaValue === "string") + message.futureQuotaValue = parseInt(object.futureQuotaValue, 10); + else if (typeof object.futureQuotaValue === "number") + message.futureQuotaValue = object.futureQuotaValue; + else if (typeof object.futureQuotaValue === "object") + message.futureQuotaValue = new $util.LongBits(object.futureQuotaValue.low >>> 0, object.futureQuotaValue.high >>> 0).toNumber(); return message; }; @@ -16375,14 +16552,49 @@ if (!options) options = {}; var object = {}; + if (options.objects || options.defaults) + object.quotaDimensions = {}; if (options.defaults) { object.subject = ""; object.description = ""; + object.apiService = ""; + object.quotaMetric = ""; + object.quotaId = ""; + if ($util.Long) { + var long = new $util.Long(0, 0, false); + object.quotaValue = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.quotaValue = options.longs === String ? "0" : 0; } if (message.subject != null && message.hasOwnProperty("subject")) object.subject = message.subject; if (message.description != null && message.hasOwnProperty("description")) object.description = message.description; + if (message.apiService != null && message.hasOwnProperty("apiService")) + object.apiService = message.apiService; + if (message.quotaMetric != null && message.hasOwnProperty("quotaMetric")) + object.quotaMetric = message.quotaMetric; + if (message.quotaId != null && message.hasOwnProperty("quotaId")) + object.quotaId = message.quotaId; + var keys2; + if (message.quotaDimensions && (keys2 = Object.keys(message.quotaDimensions)).length) { + object.quotaDimensions = {}; + for (var j = 0; j < keys2.length; ++j) + object.quotaDimensions[keys2[j]] = message.quotaDimensions[keys2[j]]; + } + if (message.quotaValue != null && message.hasOwnProperty("quotaValue")) + if (typeof message.quotaValue === "number") + object.quotaValue = options.longs === String ? String(message.quotaValue) : message.quotaValue; + else + object.quotaValue = options.longs === String ? $util.Long.prototype.toString.call(message.quotaValue) : options.longs === Number ? new $util.LongBits(message.quotaValue.low >>> 0, message.quotaValue.high >>> 0).toNumber() : message.quotaValue; + if (message.futureQuotaValue != null && message.hasOwnProperty("futureQuotaValue")) { + if (typeof message.futureQuotaValue === "number") + object.futureQuotaValue = options.longs === String ? String(message.futureQuotaValue) : message.futureQuotaValue; + else + object.futureQuotaValue = options.longs === String ? $util.Long.prototype.toString.call(message.futureQuotaValue) : options.longs === Number ? new $util.LongBits(message.futureQuotaValue.low >>> 0, message.futureQuotaValue.high >>> 0).toNumber() : message.futureQuotaValue; + if (options.oneofs) + object._futureQuotaValue = "futureQuotaValue"; + } return object; }; @@ -17127,6 +17339,8 @@ * @interface IFieldViolation * @property {string|null} [field] FieldViolation field * @property {string|null} [description] FieldViolation description + * @property {string|null} [reason] FieldViolation reason + * @property {google.rpc.ILocalizedMessage|null} [localizedMessage] FieldViolation localizedMessage */ /** @@ -17160,6 +17374,22 @@ */ FieldViolation.prototype.description = ""; + /** + * FieldViolation reason. + * @member {string} reason + * @memberof google.rpc.BadRequest.FieldViolation + * @instance + */ + FieldViolation.prototype.reason = ""; + + /** + * FieldViolation localizedMessage. + * @member {google.rpc.ILocalizedMessage|null|undefined} localizedMessage + * @memberof google.rpc.BadRequest.FieldViolation + * @instance + */ + FieldViolation.prototype.localizedMessage = null; + /** * Creates a new FieldViolation instance using the specified properties. * @function create @@ -17188,6 +17418,10 @@ writer.uint32(/* id 1, wireType 2 =*/10).string(message.field); if (message.description != null && Object.hasOwnProperty.call(message, "description")) writer.uint32(/* id 2, wireType 2 =*/18).string(message.description); + if (message.reason != null && Object.hasOwnProperty.call(message, "reason")) + writer.uint32(/* id 3, wireType 2 =*/26).string(message.reason); + if (message.localizedMessage != null && Object.hasOwnProperty.call(message, "localizedMessage")) + $root.google.rpc.LocalizedMessage.encode(message.localizedMessage, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim(); return writer; }; @@ -17232,6 +17466,14 @@ message.description = reader.string(); break; } + case 3: { + message.reason = reader.string(); + break; + } + case 4: { + message.localizedMessage = $root.google.rpc.LocalizedMessage.decode(reader, reader.uint32()); + break; + } default: reader.skipType(tag & 7); break; @@ -17273,6 +17515,14 @@ if (message.description != null && message.hasOwnProperty("description")) if (!$util.isString(message.description)) return "description: string expected"; + if (message.reason != null && message.hasOwnProperty("reason")) + if (!$util.isString(message.reason)) + return "reason: string expected"; + if (message.localizedMessage != null && message.hasOwnProperty("localizedMessage")) { + var error = $root.google.rpc.LocalizedMessage.verify(message.localizedMessage); + if (error) + return "localizedMessage." + error; + } return null; }; @@ -17292,6 +17542,13 @@ message.field = String(object.field); if (object.description != null) message.description = String(object.description); + if (object.reason != null) + message.reason = String(object.reason); + if (object.localizedMessage != null) { + if (typeof object.localizedMessage !== "object") + throw TypeError(".google.rpc.BadRequest.FieldViolation.localizedMessage: object expected"); + message.localizedMessage = $root.google.rpc.LocalizedMessage.fromObject(object.localizedMessage); + } return message; }; @@ -17311,11 +17568,17 @@ if (options.defaults) { object.field = ""; object.description = ""; + object.reason = ""; + object.localizedMessage = null; } if (message.field != null && message.hasOwnProperty("field")) object.field = message.field; if (message.description != null && message.hasOwnProperty("description")) object.description = message.description; + if (message.reason != null && message.hasOwnProperty("reason")) + object.reason = message.reason; + if (message.localizedMessage != null && message.hasOwnProperty("localizedMessage")) + object.localizedMessage = $root.google.rpc.LocalizedMessage.toObject(message.localizedMessage, options); return object; }; diff --git a/handwritten/spanner/protos/protos.json b/handwritten/spanner/protos/protos.json index 99fcec0aa89..0198eb2563b 100644 --- a/handwritten/spanner/protos/protos.json +++ b/handwritten/spanner/protos/protos.json @@ -1619,6 +1619,13 @@ }, "nested": { "Violation": { + "oneofs": { + "_futureQuotaValue": { + "oneof": [ + "futureQuotaValue" + ] + } + }, "fields": { "subject": { "type": "string", @@ -1627,6 +1634,34 @@ "description": { "type": "string", "id": 2 + }, + "apiService": { + "type": "string", + "id": 3 + }, + "quotaMetric": { + "type": "string", + "id": 4 + }, + "quotaId": { + "type": "string", + "id": 5 + }, + "quotaDimensions": { + "keyType": "string", + "type": "string", + "id": 6 + }, + "quotaValue": { + "type": "int64", + "id": 7 + }, + "futureQuotaValue": { + "type": "int64", + "id": 8, + "options": { + "proto3_optional": true + } } } } @@ -1677,6 +1712,14 @@ "description": { "type": "string", "id": 2 + }, + "reason": { + "type": "string", + "id": 3 + }, + "localizedMessage": { + "type": "LocalizedMessage", + "id": 4 } } } diff --git a/handwritten/spanner/tsconfig.json b/handwritten/spanner/tsconfig.json index 7721598f2d9..9bff90d1e79 100644 --- a/handwritten/spanner/tsconfig.json +++ b/handwritten/spanner/tsconfig.json @@ -22,8 +22,8 @@ "benchmark/*.ts", "observability-test/*.ts", "src/**/*.json", - "system-test/*.ts", "protos/protos.json", - "samples/**/*.d.ts" + "samples/**/*.d.ts", + "google-cloud-spanner-executor/**/*.ts" ] -} +} \ No newline at end of file