catalog: minor internal optimisation

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-02-22 22:37:18 +01:00
parent 679e08b78c
commit fbf382f1f8
4 changed files with 161 additions and 51 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Minor internal optimisation
@@ -17,33 +17,11 @@
import { Knex } from 'knex';
import splitToChunks from 'lodash/chunk';
import { v4 as uuid } from 'uuid';
import { ErrorLike, isError } from '@backstage/errors';
import { StitchingStrategy } from '../../../stitching/types';
import { setTimeout as sleep } from 'node:timers/promises';
import { DbFinalEntitiesRow, DbRefreshStateRow } from '../../tables';
import { retryOnDeadlock } from '../../util';
const UPDATE_CHUNK_SIZE = 100; // Smaller chunks reduce contention
const DEADLOCK_RETRY_ATTEMPTS = 3;
const DEADLOCK_BASE_DELAY_MS = 25;
// PostgreSQL deadlock error code
const POSTGRES_DEADLOCK_SQLSTATE = '40P01';
/**
* Checks if the given error is a deadlock error for the database engine in use.
*/
function isDeadlockError(
knex: Knex | Knex.Transaction,
e: unknown,
): e is ErrorLike {
if (knex.client.config.client.includes('pg')) {
// PostgreSQL deadlock detection
return isError(e) && e.code === POSTGRES_DEADLOCK_SQLSTATE;
}
// Add more database engine checks here as needed
return false;
}
/**
* Marks a number of entities for stitching some time in the near
@@ -69,12 +47,7 @@ export async function markForStitching(options: {
.update({
hash: 'force-stitching',
})
.whereIn(
'entity_id',
knex<DbRefreshStateRow>('refresh_state')
.select('entity_id')
.whereIn('entity_ref', chunk),
);
.whereIn('entity_ref', chunk);
await retryOnDeadlock(async () => {
await knex
.table<DbRefreshStateRow>('refresh_state')
@@ -143,24 +116,3 @@ function sortSplit(input: Iterable<string> | undefined): string[][] {
array.sort();
return splitToChunks(array, UPDATE_CHUNK_SIZE);
}
async function retryOnDeadlock<T>(
fn: () => Promise<T>,
knex: Knex | Knex.Transaction,
retries = DEADLOCK_RETRY_ATTEMPTS,
baseMs = DEADLOCK_BASE_DELAY_MS,
): Promise<T> {
let attempt = 0;
for (;;) {
try {
return await fn();
} catch (e: unknown) {
if (isDeadlockError(knex, e) && attempt < retries) {
await sleep(baseMs * Math.pow(2, attempt));
attempt++;
continue;
}
throw e;
}
}
}
@@ -0,0 +1,110 @@
/*
* Copyright 2026 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 { Knex } from 'knex';
import { retryOnDeadlock } from './util';
function mockKnex(client: string): Knex {
return { client: { config: { client } } } as unknown as Knex;
}
function pgDeadlockError(): Error & { code: string } {
const err = new Error('deadlock detected') as Error & { code: string };
err.code = '40P01';
return err;
}
describe('retryOnDeadlock', () => {
it('returns the result on success', async () => {
const fn = jest.fn().mockResolvedValue('ok');
const result = await retryOnDeadlock(fn, mockKnex('pg'), 3, 1);
expect(result).toBe('ok');
expect(fn).toHaveBeenCalledTimes(1);
});
it('retries on PostgreSQL deadlock errors', async () => {
const fn = jest
.fn()
.mockRejectedValueOnce(pgDeadlockError())
.mockRejectedValueOnce(pgDeadlockError())
.mockResolvedValue('recovered');
const result = await retryOnDeadlock(fn, mockKnex('pg'), 3, 1);
expect(result).toBe('recovered');
expect(fn).toHaveBeenCalledTimes(3);
});
it('throws after exhausting all retries', async () => {
const fn = jest.fn().mockRejectedValue(pgDeadlockError());
await expect(retryOnDeadlock(fn, mockKnex('pg'), 3, 1)).rejects.toThrow(
'deadlock detected',
);
// 1 initial + 3 retries = 4 calls
expect(fn).toHaveBeenCalledTimes(4);
});
it('does not retry non-deadlock errors on PostgreSQL', async () => {
const err = new Error('something else');
const fn = jest.fn().mockRejectedValue(err);
await expect(retryOnDeadlock(fn, mockKnex('pg'), 3, 1)).rejects.toThrow(
'something else',
);
expect(fn).toHaveBeenCalledTimes(1);
});
it('does not retry deadlock-like errors on non-PostgreSQL engines', async () => {
const fn = jest.fn().mockRejectedValue(pgDeadlockError());
await expect(
retryOnDeadlock(fn, mockKnex('better-sqlite3'), 3, 1),
).rejects.toThrow('deadlock detected');
expect(fn).toHaveBeenCalledTimes(1);
});
it('applies exponential backoff between retries', async () => {
const timestamps: number[] = [];
const fn = jest.fn().mockImplementation(async () => {
timestamps.push(Date.now());
if (timestamps.length <= 3) {
throw pgDeadlockError();
}
return 'done';
});
const baseMs = 50;
await retryOnDeadlock(fn, mockKnex('pg'), 3, baseMs);
expect(fn).toHaveBeenCalledTimes(4);
// Verify delays increase (with some tolerance for timing)
const delay1 = timestamps[1] - timestamps[0];
const delay2 = timestamps[2] - timestamps[1];
const delay3 = timestamps[3] - timestamps[2];
expect(delay1).toBeGreaterThanOrEqual(baseMs * 0.8);
expect(delay2).toBeGreaterThanOrEqual(baseMs * 2 * 0.8);
expect(delay3).toBeGreaterThanOrEqual(baseMs * 4 * 0.8);
});
it('defaults to 3 retries when not specified', async () => {
const fn = jest.fn().mockRejectedValue(pgDeadlockError());
await expect(retryOnDeadlock(fn, mockKnex('pg'))).rejects.toThrow(
'deadlock detected',
);
expect(fn).toHaveBeenCalledTimes(4);
});
});
+44 -1
View File
@@ -15,8 +15,11 @@
*/
import { Entity } from '@backstage/catalog-model';
import { createHash } from 'node:crypto';
import { ErrorLike, isError } from '@backstage/errors';
import stableStringify from 'fast-json-stable-stringify';
import { Knex } from 'knex';
import { createHash } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
export function generateStableHash(entity: Entity) {
return createHash('sha1')
@@ -31,3 +34,43 @@ export function generateTargetKey(target: string) {
.digest('hex')}`
: target;
}
/**
* Retries an operation on database deadlock errors.
*/
export async function retryOnDeadlock<T>(
fn: () => Promise<T>,
knex: Knex | Knex.Transaction,
retries = 3,
baseMs = 25,
): Promise<T> {
let attempt = 0;
for (;;) {
try {
return await fn();
} catch (e: unknown) {
if (isDeadlockError(knex, e) && attempt < retries) {
await sleep(baseMs * Math.pow(2, attempt));
attempt++;
continue;
}
throw e;
}
}
}
/**
* Checks if the given error is a deadlock error for the database engine in use.
*/
function isDeadlockError(
knex: Knex | Knex.Transaction,
e: unknown,
): e is ErrorLike {
if (knex.client.config.client.includes('pg')) {
// PostgreSQL deadlock detection via error code
return isError(e) && e.code === '40P01';
}
// Add more database engine checks here as needed
return false;
}