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:
Patrik Oldsberg
2026-04-13 19:47:25 +02:00
parent 925a63e27e
commit f44c6bd265
8 changed files with 183 additions and 195 deletions
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
Deduplicated internal readiness-polling helpers used by the database and cache test infrastructure.
+75
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
}