Skip to content

Commit 1e6eff6

Browse files
fix(adoption-insights): partition overlap error and auto correct the invalid partitions (#790)
* fix partition overlap error and auto correct the invalid partitions * add changeset
1 parent 19c04f7 commit 1e6eff6

5 files changed

Lines changed: 252 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-adoption-insights-backend': patch
3+
---
4+
5+
Fix partition overlap error
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { createPartition } from './partition';
17+
import { Knex } from 'knex';
18+
19+
const mockRaw = jest.fn();
20+
const knex = {
21+
schema: { raw: mockRaw },
22+
} as unknown as Knex;
23+
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
});
27+
28+
describe('createPartition', () => {
29+
it('should create a partition without errors', async () => {
30+
mockRaw.mockResolvedValueOnce(undefined);
31+
32+
await createPartition(knex, 2025, 5);
33+
34+
expect(mockRaw).toHaveBeenCalledWith(
35+
expect.stringContaining(`CREATE TABLE IF NOT EXISTS events_2025_05`),
36+
);
37+
});
38+
39+
it('should handle overlapping partition error and retry', async () => {
40+
const overlapError = new Error(
41+
'partition "events_2025_05" would overlap partition "events_2025_04"',
42+
);
43+
44+
mockRaw
45+
.mockRejectedValueOnce(overlapError) // create fails
46+
.mockResolvedValueOnce(undefined) // drop overlap partition
47+
.mockResolvedValueOnce(undefined) // recreate dropped partition
48+
.mockResolvedValueOnce(undefined); // retry create current partition
49+
50+
await createPartition(knex, 2025, 5);
51+
52+
expect(mockRaw).toHaveBeenCalledWith(
53+
expect.stringContaining(`DROP TABLE IF EXISTS events_2025_04 CASCADE`),
54+
);
55+
expect(mockRaw).toHaveBeenCalledWith(
56+
expect.stringContaining(`CREATE TABLE IF NOT EXISTS events_2025_04`),
57+
);
58+
expect(mockRaw).toHaveBeenCalledWith(
59+
expect.stringContaining(`CREATE TABLE IF NOT EXISTS events_2025_05`),
60+
);
61+
});
62+
63+
it('should handle max retry when recreating dropped partition', async () => {
64+
const overlapError = new Error(
65+
'partition "events_2025_05" would overlap partition "events_2025_04"',
66+
);
67+
68+
mockRaw
69+
.mockRejectedValueOnce(overlapError) // create fails for events_2025_05
70+
.mockResolvedValueOnce(undefined) // drop overlap partition events_2025_04
71+
.mockRejectedValueOnce(overlapError) // create fails again for events_2025_04
72+
.mockResolvedValueOnce(undefined); // retry create current partition - events_2025_05
73+
74+
await expect(createPartition(knex, 2025, 5)).rejects.toThrow(
75+
'Exceeded max retries for partition 2025_4',
76+
);
77+
});
78+
79+
it('should not retry for non-overlap errors', async () => {
80+
const otherError = new Error('some other SQL error');
81+
mockRaw.mockRejectedValueOnce(otherError);
82+
83+
await expect(createPartition(knex, 2025, 6)).rejects.toThrow(
84+
'some other SQL error',
85+
);
86+
87+
expect(mockRaw).toHaveBeenCalledTimes(1);
88+
});
89+
});

workspaces/adoption-insights/plugins/adoption-insights-backend/src/database/partition.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,61 @@ import {
1818
LoggerService,
1919
SchedulerService,
2020
} from '@backstage/backend-plugin-api/index';
21+
import {
22+
extractOverlappingPartition,
23+
isPartitionOverlapError,
24+
parsePartitionDate,
25+
} from '../utils/partition';
26+
27+
type AttemptTracker = Map<string, number>;
2128

2229
export const createPartition = async (
2330
knex: Knex,
2431
year: number,
2532
month: number,
33+
attempts: AttemptTracker = new Map(),
34+
maxRetries = 1,
2635
) => {
27-
const startDate = `${year}-${month.toString().padStart(2, '0')}-01`;
28-
const nextMonth = new Date(year, month, 1);
29-
nextMonth.setMonth(nextMonth.getMonth() + 1);
30-
const endDate = `${nextMonth.getFullYear()}-${(nextMonth.getMonth() + 1)
31-
.toString()
32-
.padStart(2, '0')}-01`;
36+
const start = new Date(year, month - 1, 1);
37+
const end = new Date(year, month, 1);
38+
39+
const startDate = start.toISOString().slice(0, 10);
40+
const endDate = end.toISOString().slice(0, 10);
41+
42+
const partitionName = `events_${year}_${month.toString().padStart(2, '0')}`;
43+
const key = `${year}_${month}`;
3344

34-
const partitionName = `events_${year}_${month}`;
45+
// track max attempts
46+
const currentAttempt = attempts.get(key) ?? 0;
47+
if (currentAttempt > maxRetries) {
48+
throw new Error(`Exceeded max retries for partition ${key}`);
49+
}
50+
attempts.set(key, currentAttempt + 1);
3551

36-
await knex.schema.raw(`
52+
try {
53+
await knex.schema.raw(`
3754
CREATE TABLE IF NOT EXISTS ${partitionName}
3855
PARTITION OF events
3956
FOR VALUES FROM ('${startDate}') TO ('${endDate}');
4057
`);
58+
} catch (error) {
59+
if (isPartitionOverlapError(error)) {
60+
const overlappingPartition = extractOverlappingPartition(error.message);
61+
const { year: y, month: m } = parsePartitionDate(overlappingPartition);
62+
63+
await knex.schema.raw(
64+
`DROP TABLE IF EXISTS ${overlappingPartition} CASCADE`,
65+
);
66+
67+
// Recreate the dropped overlapping partition
68+
await createPartition(knex, y, m, attempts, maxRetries);
69+
70+
// Retry the current one
71+
await createPartition(knex, year, month, attempts, maxRetries);
72+
} else {
73+
throw error;
74+
}
75+
}
4176
};
4277

4378
export const schedulePartition = async (
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import {
17+
extractOverlappingPartition,
18+
parsePartitionDate,
19+
isPartitionOverlapError,
20+
} from './partition';
21+
22+
describe('extractOverlappingPartition', () => {
23+
it('should extract the overlapping partition name', () => {
24+
const msg =
25+
'partition "events_2025_05" would overlap partition "events_2025_04"';
26+
expect(extractOverlappingPartition(msg)).toBe('events_2025_04');
27+
});
28+
29+
it('should return empty string if pattern does not match', () => {
30+
const msg = 'some other error message';
31+
expect(extractOverlappingPartition(msg)).toBe('');
32+
});
33+
});
34+
35+
describe('parsePartitionDate', () => {
36+
it('should parse valid partition name', () => {
37+
const input = 'events_2025_04';
38+
expect(parsePartitionDate(input)).toEqual({ year: 2025, month: 4 });
39+
});
40+
41+
it('should parse single-digit month correctly', () => {
42+
const input = 'events_2025_9';
43+
expect(parsePartitionDate(input)).toEqual({ year: 2025, month: 9 });
44+
});
45+
46+
it('should throw error for invalid format', () => {
47+
const input = 'invalid_partition_name';
48+
expect(() => parsePartitionDate(input)).toThrow(
49+
'Cannot parse partition name: invalid_partition_name',
50+
);
51+
});
52+
});
53+
54+
describe('isPartitionOverlapError', () => {
55+
it('should return true for overlap error', () => {
56+
const err = {
57+
message:
58+
'partition "events_2025_05" would overlap partition "events_2025_04"',
59+
};
60+
expect(isPartitionOverlapError(err)).toBe(true);
61+
});
62+
63+
it('should return false for non-overlap error', () => {
64+
const err = { message: 'some other error' };
65+
expect(isPartitionOverlapError(err)).toBe(false);
66+
});
67+
68+
it('should return false if message is missing', () => {
69+
const err = { msg: 'missing message property' };
70+
expect(isPartitionOverlapError(err)).toBe(false);
71+
});
72+
73+
it('should return false for null error', () => {
74+
expect(isPartitionOverlapError(null)).toBe(false);
75+
});
76+
77+
it('should return false for non-object error', () => {
78+
expect(isPartitionOverlapError('error string')).toBe(false);
79+
});
80+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
export const extractOverlappingPartition = (message: string): string => {
17+
const match = message.match(/would overlap partition "(.*?)"$/);
18+
return match?.[1] || '';
19+
};
20+
21+
export const parsePartitionDate = (
22+
name: string,
23+
): { year: number; month: number } => {
24+
const match = name.match(/events_(\d{4})_(\d{1,2})/);
25+
if (!match) throw new Error(`Cannot parse partition name: ${name}`);
26+
return { year: parseInt(match[1], 10), month: parseInt(match[2], 10) };
27+
};
28+
29+
export const isPartitionOverlapError = (error: unknown): boolean => {
30+
return (
31+
error !== null &&
32+
typeof (error as any).message === 'string' &&
33+
(error as any).message.includes('would overlap partition')
34+
);
35+
};

0 commit comments

Comments
 (0)