Files
Stephen RosaandGitHub b8af3fc56d feat(data-connect): add de-duplication, resume, and intelligent reconnection with backoff (#9905)
* chore(data-connect): update documentation and names in stream transport (#9846)

* rename in preparation for future changes

* finish renaming + updating comments

* minor typo fix

* feat(data-connect): add query de-duplication to stream transport (#9850)

* rename in preparation for future changes

* finish renaming + updating comments

* minor typo fix

* add de-duping - now for tests

* add de-duplication tests for invokeQuery

* finish invoke operation dedupe tests

* add subscribe de-duping as well

* add more query dedupe tests

* chore(data-connect): clean up streaming tests a bit (#9851)

* rename in preparation for future changes

* finish renaming + updating comments

* minor typo fix

* add de-duping - now for tests

* add de-duplication tests for invokeQuery

* finish invoke operation dedupe tests

* add subscribe de-duping as well

* add more query dedupe tests

* clean up stream tests

* clean up promise types in streaming tests

* Pasta/bucatini resume refactor (#9885)

* add test changes

* add transportchanges

* finalize changes, run yarn format

* finalize changes based on reviewer feedback

* feat(data-connect): add prefer resume over execute optimization to stream transport (#9879)

* initial implementation - now for testing

* finish tests

* merge pasta/bucatini-resume-refactor

* address reviewer comments

* remove old comments

* address reviewer comments

* feat(data-connect): add intelligent reconnection to streaming transport and harden disconnect/cleanup functionality (#9893)

* WIP

* first pass

* fixing names

* tidying up, fixing tests

* fixed tests

* address reviewer comments

* address reviewer comments and harden close/cleanup lifecycle

* address reviewer comments

* make listener registering and unregistering more robust

* merge main, add changeset
2026-05-05 11:41:15 -07:00

375 lines
13 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
*
* 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 { expect } from 'chai';
import * as chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import * as sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { DataConnectOptions } from '../../src/api/DataConnect';
import { Code } from '../../src/core/error';
import * as logger from '../../src/logger';
import {
WEBSOCKET_CLOSE_CODE,
WebSocketTransport,
initializeWebSocket
} from '../../src/network/stream/websocket';
import { DataConnectStreamRequest } from '../../src/network/stream/wire';
import { DataConnectResponse } from '../../src/network/transport';
import { expectIsNotSettled, MockWebSocket } from './testUtils';
chai.use(sinonChai);
chai.use(chaiAsPromised);
/** Interface that exposes some private fields and methods of WebSocketTransport for testing purposes. */
interface TransportWithInternals {
openConnection(): Promise<void>;
closeConnection(): Promise<void>;
sendMessage(req: DataConnectStreamRequest<unknown>): Promise<void>;
connection: MockWebSocket | undefined;
streamIsReady: boolean;
handleResponse<Data>(
requestId: string,
response: DataConnectResponse<Data>
): Promise<void>;
rejectAllRequests(code: Code, reason: string): void;
}
describe('WebSocketTransport', () => {
const dcOptions: DataConnectOptions = {
projectId: 'p',
location: 'l',
service: 's',
connector: 'c'
};
let transport: TransportWithInternals;
beforeEach(() => {
initializeWebSocket(MockWebSocket as unknown as typeof WebSocket);
transport = new WebSocketTransport(
dcOptions
) as unknown as TransportWithInternals;
});
afterEach(() => {
initializeWebSocket(WebSocket);
sinon.restore();
});
const connectionError = new Error('Test connection error');
describe('openConnection', () => {
it('should resolve when onopen fires', async () => {
const openPromise = transport.openConnection();
await expectIsNotSettled(openPromise);
await transport.connection!.simulateOpen();
await expect(openPromise).to.be.fulfilled;
});
it('should reject when onerror fires', async () => {
const openPromise = transport.openConnection();
await expectIsNotSettled(openPromise);
await transport.connection!.simulateError(connectionError);
await expect(openPromise).to.be.rejectedWith(/Failed to open connection/);
});
it('should de-duplicate calls when already connected', async () => {
const openPromise1 = transport.openConnection();
const onOpenSpy = sinon.spy(transport.connection!, 'onopen');
await transport.connection!.simulateOpen();
await openPromise1;
const previousConnection = transport.connection;
const openPromise2 = transport.openConnection();
await openPromise2;
expect(transport.connection).to.equal(previousConnection);
expect(onOpenSpy).to.have.been.calledOnce;
});
it('should de-duplicate calls when open connection is pending', async () => {
const openPromise1 = transport.openConnection();
const onOpenSpy = sinon.spy(transport.connection!, 'onopen');
await expectIsNotSettled(openPromise1);
const openPromise2 = transport.openConnection();
await expectIsNotSettled(openPromise2);
await transport.connection!.simulateOpen();
await expect(openPromise1).to.be.fulfilled;
await expect(openPromise2).to.be.fulfilled;
expect(onOpenSpy).to.have.been.calledOnce;
});
});
describe('streamIsReady', () => {
it('should be false initially', () => {
expect(transport.streamIsReady).to.be.false;
});
it('should be false while connecting', async () => {
const openPromise = transport.openConnection();
await expectIsNotSettled(openPromise);
expect(transport.streamIsReady).to.be.false;
});
it('should be true when connected', async () => {
const openPromise = transport.openConnection();
await transport.connection!.simulateOpen();
await openPromise;
expect(transport.streamIsReady).to.be.true;
});
it('should be false after disconnected', async () => {
const openPromise = transport.openConnection();
await transport.connection!.simulateOpen();
await openPromise;
await transport.connection!.simulateClose();
expect(transport.streamIsReady).to.be.false;
});
});
describe('closeConnection', () => {
it('should close the underlying websocket and reset connection states', async () => {
const openPromise = transport.openConnection();
await transport.connection!.simulateOpen();
await openPromise;
const mockWs = transport.connection!;
await transport.closeConnection();
expect(mockWs.close).to.have.been.calledOnce;
expect(transport.connection).to.be.undefined;
});
it('should be idempotent if connection is already closed', async () => {
await expect(transport.closeConnection()).to.be.fulfilled;
});
it('should return rejected promise and clean up if ws.close() throws', async () => {
const openPromise = transport.openConnection();
await transport.connection!.simulateOpen();
await openPromise;
const mockWs = transport.connection!;
mockWs.close = sinon.stub().throws(connectionError);
await expect(transport.closeConnection()).to.be.rejectedWith(
connectionError
);
expect(transport.connection).to.be.undefined;
});
});
describe('onclose handler (handleWebsocketDisconnect)', () => {
it('should reset connection state when websocket is closed externally', async () => {
const openPromise = transport.openConnection();
await transport.connection!.simulateOpen();
await openPromise;
expect(transport.connection).to.not.be.undefined;
// simulate server drop / unexpected closure
await transport.connection!.simulateClose();
expect(transport.connection).to.be.undefined;
});
it('should call rejectAllRequests and clean up when closed externally', async () => {
const openPromise = transport.openConnection();
await transport.connection!.simulateOpen();
await openPromise;
const rejectSpy = sinon.spy(transport, 'rejectAllRequests');
await transport.connection!.simulateClose();
expect(rejectSpy).to.have.been.calledOnce;
expect(rejectSpy.firstCall.args[0]).to.equal(Code.OTHER);
expect(rejectSpy.firstCall.args[1]).to.equal(
`Stream disconnected while idle with code ${WEBSOCKET_CLOSE_CODE}: Normal Closure`
);
});
});
describe('sendMessage', () => {
const payload: DataConnectStreamRequest<unknown> = {
requestId: '1',
execute: { operationName: 'testQuery' }
};
it('should not send until connection is open', async () => {
const sendPromise = transport.sendMessage(payload);
await expectIsNotSettled(sendPromise);
await transport.connection!.simulateOpen();
await sendPromise;
expect(transport.connection!.send).to.have.been.calledOnce;
});
it('should send stringified json payload', async () => {
const sendPromise = transport.sendMessage(payload);
await transport.connection!.simulateOpen();
await sendPromise;
expect(transport.connection!.send).to.have.been.calledOnceWith(
JSON.stringify(payload)
);
});
});
describe('handleWebSocketMessage', () => {
let logErrorStub: sinon.SinonStub;
beforeEach(() => {
logErrorStub = sinon.stub(logger, 'logError');
});
const messageWithExtensions = {
result: {
requestId: '1',
data: { foo: 'bar' },
errors: [],
extensions: { dataConnect: [{ entityIds: ['id1'] }] }
}
};
const messageWithoutExtensions = {
result: {
requestId: '2',
data: { abc: 'xyz' },
errors: []
}
};
it('should correctly parse incoming JSON and handle response', async () => {
const handleResponseStub = sinon
.stub(transport, 'handleResponse')
.resolves();
const openPromise = transport.openConnection();
await transport.connection!.simulateOpen();
await openPromise;
await transport.connection!.simulateMessage(
JSON.stringify(messageWithExtensions)
);
expect(handleResponseStub).to.have.been.calledOnce;
const [calledRequestId, calledResponse] =
handleResponseStub.firstCall.args;
expect(calledRequestId).to.equal(messageWithExtensions.result.requestId);
expect(calledResponse.data).to.deep.equal(
messageWithExtensions.result.data
);
expect(calledResponse.errors).to.deep.equal(
messageWithExtensions.result.errors
);
expect(calledResponse.extensions).to.deep.equal(
messageWithExtensions.result.extensions
);
});
it('should map extensions to empty array if not strictly provided', async () => {
const handleResponseStub = sinon
.stub(transport, 'handleResponse')
.resolves();
const openPromise = transport.openConnection();
await transport.connection!.simulateOpen();
await openPromise;
await transport.connection!.simulateMessage(
JSON.stringify(messageWithoutExtensions)
);
expect(handleResponseStub).to.have.been.calledOnce;
const calledResponse = handleResponseStub.firstCall.args[1];
expect(calledResponse.extensions).to.deep.equal({ dataConnect: [] });
});
it('should close connection with error if message is not an object', async () => {
const openPromise = transport.openConnection();
const mockWs = transport.connection!;
await mockWs.simulateOpen();
await openPromise;
await mockWs.simulateMessage(
JSON.stringify('this is a string, not an object')
);
expect(mockWs.close).to.have.been.calledOnceWith(
WEBSOCKET_CLOSE_CODE,
'WebSocket message is not an object'
);
expect(logErrorStub).to.have.been.calledOnce;
expect(logErrorStub).to.have.been.calledWithMatch(
'DataConnect WebSocket error, closing stream'
);
});
it('should close connection with error if result is missing', async () => {
const openPromise = transport.openConnection();
const mockWs = transport.connection!;
await mockWs.simulateOpen();
await openPromise;
const invalidData = { foo: 'bar' }; // no result object
await mockWs.simulateMessage(JSON.stringify(invalidData));
expect(mockWs.close).to.have.been.calledOnceWith(
WEBSOCKET_CLOSE_CODE,
'WebSocket message from emulator did not include result'
);
expect(logErrorStub).to.have.been.calledOnce;
expect(logErrorStub).to.have.been.calledWithMatch(
'DataConnect WebSocket error, closing stream'
);
});
it('should close connection with error if result is not an object', async () => {
const openPromise = transport.openConnection();
const mockWs = transport.connection!;
await mockWs.simulateOpen();
await openPromise;
const invalidData = { result: 'string result' };
await mockWs.simulateMessage(JSON.stringify(invalidData));
expect(mockWs.close).to.have.been.calledOnceWith(
WEBSOCKET_CLOSE_CODE,
'WebSocket message result is not an object'
);
expect(logErrorStub).to.have.been.calledOnce;
expect(logErrorStub).to.have.been.calledWithMatch(
'DataConnect WebSocket error, closing stream'
);
});
it('should close connection with error if requestId is missing', async () => {
const openPromise = transport.openConnection();
const mockWs = transport.connection!;
await mockWs.simulateOpen();
await openPromise;
const invalidData = { result: { foo: 'bar' } }; // no requestId
await mockWs.simulateMessage(JSON.stringify(invalidData));
expect(mockWs.close).to.have.been.calledOnceWith(
WEBSOCKET_CLOSE_CODE,
'WebSocket message did not include requestId'
);
expect(logErrorStub).to.have.been.calledOnce;
expect(logErrorStub).to.have.been.calledWithMatch(
'DataConnect WebSocket error, closing stream'
);
});
});
});