diff --git a/lib/DBSQLOperation.ts b/lib/DBSQLOperation.ts index 634749bf..fe22995d 100644 --- a/lib/DBSQLOperation.ts +++ b/lib/DBSQLOperation.ts @@ -66,6 +66,8 @@ export default class DBSQLOperation implements IOperation { private metadata?: TGetResultSetMetadataResp; + private metadataPromise?: Promise; + private state: TOperationState = TOperationState.INITIALIZED_STATE; // Once operation is finished or fails - cache status response, because subsequent calls @@ -292,6 +294,12 @@ export default class DBSQLOperation implements IOperation { return false; } + // Wait for operation to finish before checking for more rows + // This ensures metadata can be fetched successfully + if (this.operationHandle.hasResultSet) { + await this.waitUntilReady(); + } + // If we fetched all the data from server - check if there's anything buffered in result handler const resultHandler = await this.getResultHandler(); return resultHandler.hasMore(); @@ -383,16 +391,33 @@ export default class DBSQLOperation implements IOperation { } private async fetchMetadata() { - if (!this.metadata) { + // If metadata is already cached, return it immediately + if (this.metadata) { + return this.metadata; + } + + // If a fetch is already in progress, wait for it to complete + if (this.metadataPromise) { + return this.metadataPromise; + } + + // Start a new fetch and cache the promise to prevent concurrent fetches + this.metadataPromise = (async () => { const driver = await this.context.getDriver(); const metadata = await driver.getResultSetMetadata({ operationHandle: this.operationHandle, }); Status.assert(metadata.status); this.metadata = metadata; + return metadata; + })(); + + try { + return await this.metadataPromise; + } finally { + // Clear the promise once completed (success or failure) + this.metadataPromise = undefined; } - - return this.metadata; } private async getResultHandler(): Promise> { diff --git a/tests/unit/DBSQLOperation.test.ts b/tests/unit/DBSQLOperation.test.ts index 94224455..b5f142ba 100644 --- a/tests/unit/DBSQLOperation.test.ts +++ b/tests/unit/DBSQLOperation.test.ts @@ -1138,4 +1138,43 @@ describe('DBSQLOperation', () => { expect(operation['_data']['hasMoreRowsFlag']).to.be.false; }); }); + + describe('metadata fetching (async-safety)', () => { + it('should handle concurrent metadata fetch requests without duplicate server calls', async () => { + const context = new ClientContextStub(); + const driver = sinon.spy(context.driver); + driver.getOperationStatusResp.operationState = TOperationState.FINISHED_STATE; + driver.getOperationStatusResp.hasResultSet = true; + + // Create operation without direct results to force metadata fetching + const operation = new DBSQLOperation({ handle: operationHandleStub({ hasResultSet: true }), context }); + + // Trigger multiple concurrent metadata fetches + const results = await Promise.all([operation.hasMoreRows(), operation.hasMoreRows(), operation.hasMoreRows()]); + + // All should succeed + expect(results).to.deep.equal([true, true, true]); + + // But metadata should only be fetched once from server + expect(driver.getResultSetMetadata.callCount).to.equal(1); + }); + + it('should cache metadata after first fetch', async () => { + const context = new ClientContextStub(); + const driver = sinon.spy(context.driver); + driver.getOperationStatusResp.operationState = TOperationState.FINISHED_STATE; + driver.getOperationStatusResp.hasResultSet = true; + + const operation = new DBSQLOperation({ handle: operationHandleStub({ hasResultSet: true }), context }); + + // First call should fetch metadata + await operation.hasMoreRows(); + expect(driver.getResultSetMetadata.callCount).to.equal(1); + + // Subsequent calls should use cached metadata + await operation.hasMoreRows(); + await operation.hasMoreRows(); + expect(driver.getResultSetMetadata.callCount).to.equal(1); + }); + }); });