diff --git a/.changeset/deduplicate-test-readiness-polling.md b/.changeset/deduplicate-test-readiness-polling.md new file mode 100644 index 0000000000..409bfab49c --- /dev/null +++ b/.changeset/deduplicate-test-readiness-polling.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Deduplicated internal readiness-polling helpers used by the database and cache test infrastructure. diff --git a/packages/backend-test-utils/src/cache/helpers.ts b/packages/backend-test-utils/src/cache/helpers.ts new file mode 100644 index 0000000000..3f13bc8d0e --- /dev/null +++ b/packages/backend-test-utils/src/cache/helpers.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 Keyv, { type KeyvStoreAdapter } from 'keyv'; +import { v4 as uuid } from 'uuid'; +import { waitForReady } from '../util/waitForReady'; +import { Instance } from './types'; + +/** + * Polls a Keyv store until a set/get round-trip succeeds. + */ +export async function attemptKeyvConnection( + createStore: (connection: string) => KeyvStoreAdapter, + connection: string, + label: string, +): Promise { + let keyv: Keyv | undefined; + + await waitForReady(async () => { + const store = createStore(connection); + keyv = new Keyv({ store }); + const value = uuid(); + await keyv.set('test', value); + return (await keyv.get('test')) === value; + }, label); + + return keyv!; +} + +/** + * Starts a Redis-protocol-compatible container (Redis, Valkey, etc.) on port + * 6379 and waits until a Keyv round-trip succeeds. + */ +export async function startRedisLikeContainer( + image: string, + store: string, + createStore: (connection: string) => KeyvStoreAdapter, +): Promise { + // Lazy-load to avoid side-effect of importing testcontainers + const { GenericContainer } = + require('testcontainers') as typeof import('testcontainers'); + + const container = await new GenericContainer(image) + .withExposedPorts(6379) + .start(); + + const host = container.getHost(); + const port = container.getMappedPort(6379); + const connection = `redis://${host}:${port}`; + + const keyv = await attemptKeyvConnection(createStore, connection, store); + + return { + store, + connection, + keyv, + stop: async () => { + await keyv.disconnect(); + await container.stop({ timeout: 10_000 }); + }, + }; +} diff --git a/packages/backend-test-utils/src/cache/memcache.ts b/packages/backend-test-utils/src/cache/memcache.ts index e985ee1c7d..e7a29f482d 100644 --- a/packages/backend-test-utils/src/cache/memcache.ts +++ b/packages/backend-test-utils/src/cache/memcache.ts @@ -14,39 +14,20 @@ * limitations under the License. */ -import Keyv from 'keyv'; import KeyvMemcache from '@keyv/memcache'; -import { v4 as uuid } from 'uuid'; import { Instance } from './types'; +import { attemptKeyvConnection } from './helpers'; -async function attemptMemcachedConnection(connection: string): Promise { - const startTime = Date.now(); - - for (;;) { - try { - const store = new KeyvMemcache(connection); - const keyv = new Keyv({ store }); - const value = uuid(); - await keyv.set('test', value); - if ((await keyv.get('test')) === value) { - return keyv; - } - } catch (e) { - if (Date.now() - startTime > 30_000) { - throw new Error( - `Timed out waiting for memcached to be ready for connections, ${e}`, - ); - } - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } -} +const createStore = (connection: string) => new KeyvMemcache(connection); export async function connectToExternalMemcache( connection: string, ): Promise { - const keyv = await attemptMemcachedConnection(connection); + const keyv = await attemptKeyvConnection( + createStore, + connection, + 'memcached', + ); return { store: 'memcache', connection, @@ -70,7 +51,11 @@ export async function startMemcachedContainer( const port = container.getMappedPort(11211); const connection = `${host}:${port}`; - const keyv = await attemptMemcachedConnection(connection); + const keyv = await attemptKeyvConnection( + createStore, + connection, + 'memcached', + ); return { store: 'memcache', diff --git a/packages/backend-test-utils/src/cache/redis.ts b/packages/backend-test-utils/src/cache/redis.ts index cd6e5c2893..892c0bf08b 100644 --- a/packages/backend-test-utils/src/cache/redis.ts +++ b/packages/backend-test-utils/src/cache/redis.ts @@ -14,39 +14,16 @@ * limitations under the License. */ -import Keyv from 'keyv'; import KeyvRedis from '@keyv/redis'; -import { v4 as uuid } from 'uuid'; import { Instance } from './types'; +import { attemptKeyvConnection, startRedisLikeContainer } from './helpers'; -async function attemptRedisConnection(connection: string): Promise { - const startTime = Date.now(); - - for (;;) { - try { - const store = new KeyvRedis(connection); - const keyv = new Keyv({ store }); - const value = uuid(); - await keyv.set('test', value); - if ((await keyv.get('test')) === value) { - return keyv; - } - } catch (e) { - if (Date.now() - startTime > 30_000) { - throw new Error( - `Timed out waiting for redis to be ready for connections, ${e}`, - ); - } - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } -} +const createStore = (connection: string) => new KeyvRedis(connection); export async function connectToExternalRedis( connection: string, ): Promise { - const keyv = await attemptRedisConnection(connection); + const keyv = await attemptKeyvConnection(createStore, connection, 'redis'); return { store: 'redis', connection, @@ -56,27 +33,5 @@ export async function connectToExternalRedis( } export async function startRedisContainer(image: string): Promise { - // Lazy-load to avoid side-effect of importing testcontainers - const { GenericContainer } = - require('testcontainers') as typeof import('testcontainers'); - - const container = await new GenericContainer(image) - .withExposedPorts(6379) - .start(); - - const host = container.getHost(); - const port = container.getMappedPort(6379); - const connection = `redis://${host}:${port}`; - - const keyv = await attemptRedisConnection(connection); - - return { - store: 'redis', - connection, - keyv, - stop: async () => { - await keyv.disconnect(); - await container.stop({ timeout: 10_000 }); - }, - }; + return startRedisLikeContainer(image, 'redis', createStore); } diff --git a/packages/backend-test-utils/src/cache/valkey.ts b/packages/backend-test-utils/src/cache/valkey.ts index 05bb161c94..1a656bff7d 100644 --- a/packages/backend-test-utils/src/cache/valkey.ts +++ b/packages/backend-test-utils/src/cache/valkey.ts @@ -14,39 +14,16 @@ * limitations under the License. */ -import Keyv from 'keyv'; import KeyvValkey from '@keyv/valkey'; -import { v4 as uuid } from 'uuid'; import { Instance } from './types'; +import { attemptKeyvConnection, startRedisLikeContainer } from './helpers'; -async function attemptValkeyConnection(connection: string): Promise { - const startTime = Date.now(); - - for (;;) { - try { - const store = new KeyvValkey(connection); - const keyv = new Keyv({ store }); - const value = uuid(); - await keyv.set('test', value); - if ((await keyv.get('test')) === value) { - return keyv; - } - } catch (e) { - if (Date.now() - startTime > 30_000) { - throw new Error( - `Timed out waiting for valkey to be ready for connections, ${e}`, - ); - } - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } -} +const createStore = (connection: string) => new KeyvValkey(connection); export async function connectToExternalValkey( connection: string, ): Promise { - const keyv = await attemptValkeyConnection(connection); + const keyv = await attemptKeyvConnection(createStore, connection, 'valkey'); return { store: 'valkey', connection, @@ -56,27 +33,5 @@ export async function connectToExternalValkey( } export async function startValkeyContainer(image: string): Promise { - // Lazy-load to avoid side-effect of importing testcontainers - const { GenericContainer } = - require('testcontainers') as typeof import('testcontainers'); - - const container = await new GenericContainer(image) - .withExposedPorts(6379) - .start(); - - const host = container.getHost(); - const port = container.getMappedPort(6379); - const connection = `redis://${host}:${port}`; - - const keyv = await attemptValkeyConnection(connection); - - return { - store: 'valkey', - connection, - keyv, - stop: async () => { - await keyv.disconnect(); - await container.stop({ timeout: 10_000 }); - }, - }; + return startRedisLikeContainer(image, 'valkey', createStore); } diff --git a/packages/backend-test-utils/src/database/mysql.ts b/packages/backend-test-utils/src/database/mysql.ts index 5c668c71db..042aa74405 100644 --- a/packages/backend-test-utils/src/database/mysql.ts +++ b/packages/backend-test-utils/src/database/mysql.ts @@ -14,54 +14,31 @@ * limitations under the License. */ -import { stringifyError } from '@backstage/errors'; import { randomBytes } from 'node:crypto'; import knexFactory, { Knex } from 'knex'; import { v4 as uuid } from 'uuid'; import yn from 'yn'; +import { waitForReady } from '../util/waitForReady'; import { Engine, LARGER_POOL_CONFIG, TestDatabaseProperties } from './types'; async function waitForMysqlReady( connection: Knex.MySqlConnectionConfig, ): Promise { - const startTime = Date.now(); - - let lastError: Error | undefined; - let attempts = 0; - for (;;) { - attempts += 1; - - let knex: Knex | undefined; + await waitForReady(async () => { + const knex = knexFactory({ + client: 'mysql2', + connection: { + // make a copy because the driver mutates this + ...connection, + }, + }); try { - knex = knexFactory({ - client: 'mysql2', - connection: { - // make a copy because the driver mutates this - ...connection, - }, - }); const result = await knex.select(knex.raw('version() AS version')); - if (Array.isArray(result) && result[0]?.version) { - return; - } - } catch (e) { - lastError = e; + return Array.isArray(result) && Boolean(result[0]?.version); } finally { - await knex?.destroy(); + await knex.destroy(); } - - if (Date.now() - startTime > 30_000) { - throw new Error( - `Timed out waiting for the database to be ready for connections, ${attempts} attempts, ${ - lastError - ? `last error was ${stringifyError(lastError)}` - : '(no errors thrown)' - }`, - ); - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } + }, 'the database'); } export async function startMysqlContainer(image: string): Promise<{ diff --git a/packages/backend-test-utils/src/database/postgres.ts b/packages/backend-test-utils/src/database/postgres.ts index d85b005308..8d85470252 100644 --- a/packages/backend-test-utils/src/database/postgres.ts +++ b/packages/backend-test-utils/src/database/postgres.ts @@ -14,54 +14,31 @@ * limitations under the License. */ -import { stringifyError } from '@backstage/errors'; import { randomBytes } from 'node:crypto'; import knexFactory, { Knex } from 'knex'; import { parse as parsePgConnectionString } from 'pg-connection-string'; import { v4 as uuid } from 'uuid'; +import { waitForReady } from '../util/waitForReady'; import { Engine, LARGER_POOL_CONFIG, TestDatabaseProperties } from './types'; async function waitForPostgresReady( connection: Knex.PgConnectionConfig, ): Promise { - const startTime = Date.now(); - - let lastError: Error | undefined; - let attempts = 0; - for (;;) { - attempts += 1; - - let knex: Knex | undefined; + await waitForReady(async () => { + const knex = knexFactory({ + client: 'pg', + connection: { + // make a copy because the driver mutates this + ...connection, + }, + }); try { - knex = knexFactory({ - client: 'pg', - connection: { - // make a copy because the driver mutates this - ...connection, - }, - }); const result = await knex.select(knex.raw('version()')); - if (Array.isArray(result) && result[0]?.version) { - return; - } - } catch (e) { - lastError = e; + return Array.isArray(result) && Boolean(result[0]?.version); } finally { - await knex?.destroy(); + await knex.destroy(); } - - if (Date.now() - startTime > 30_000) { - throw new Error( - `Timed out waiting for the database to be ready for connections, ${attempts} attempts, ${ - lastError - ? `last error was ${stringifyError(lastError)}` - : '(no errors thrown)' - }`, - ); - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } + }, 'the database'); } export async function startPostgresContainer(image: string): Promise<{ diff --git a/packages/backend-test-utils/src/util/waitForReady.ts b/packages/backend-test-utils/src/util/waitForReady.ts new file mode 100644 index 0000000000..1aeaa109c5 --- /dev/null +++ b/packages/backend-test-utils/src/util/waitForReady.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { stringifyError } from '@backstage/errors'; + +/** + * Polls a probe function until it succeeds or the timeout is reached. + * + * @param probe - An async function that should return `true` when the + * service is ready. Throwing is treated as "not ready yet". + * @param label - A human-readable label used in the timeout error message. + * @param timeoutMs - Maximum time to wait in milliseconds (default 30 000). + */ +export async function waitForReady( + probe: () => Promise, + label: string, + timeoutMs: number = 30_000, +): Promise { + const startTime = Date.now(); + + let lastError: Error | undefined; + let attempts = 0; + for (;;) { + attempts += 1; + + try { + if (await probe()) { + return; + } + } catch (e) { + lastError = e; + } + + if (Date.now() - startTime > timeoutMs) { + throw new Error( + `Timed out waiting for ${label} to be ready for connections, ${attempts} attempts, ${ + lastError + ? `last error was ${stringifyError(lastError)}` + : '(no errors thrown)' + }`, + ); + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } +}