backend-test-utils: deduplicate test infrastructure readiness-polling helpers
Extract shared internal helpers to eliminate near-identical readiness polling loops across database and cache test infrastructure: - waitForReady: generic probe-until-ready loop used by both postgres and mysql database helpers - attemptKeyvConnection: generic Keyv set/get probe loop used by redis, valkey, and memcached cache helpers - startRedisLikeContainer: shared container-start for redis-protocol stores (redis and valkey) Also normalizes cache timeout error formatting to use stringifyError instead of template string coercion. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
Deduplicated internal readiness-polling helpers used by the database and cache test infrastructure.
|
||||
@@ -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<Keyv> {
|
||||
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<Instance> {
|
||||
// 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
+12
-27
@@ -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<Keyv> {
|
||||
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<Instance> {
|
||||
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',
|
||||
|
||||
+4
-49
@@ -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<Keyv> {
|
||||
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<Instance> {
|
||||
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<Instance> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
+4
-49
@@ -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<Keyv> {
|
||||
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<Instance> {
|
||||
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<Instance> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<{
|
||||
|
||||
@@ -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<void> {
|
||||
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<{
|
||||
|
||||
@@ -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<boolean>,
|
||||
label: string,
|
||||
timeoutMs: number = 30_000,
|
||||
): Promise<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user