fix: 收敛订阅创建锁范围
This commit is contained in:
parent
45a3a7a17c
commit
e49da68165
@ -98,17 +98,15 @@ export class QqbotMessageSubscriptionService {
|
|||||||
const normalized = await this.normalizeInput(input);
|
const normalized = await this.normalizeInput(input);
|
||||||
try {
|
try {
|
||||||
const saved = await this.subscriptionRepository.manager.transaction(
|
const saved = await this.subscriptionRepository.manager.transaction(
|
||||||
/** Serializes active-key ownership and deleted-row revival in one transaction. */
|
/** Lets the unique active key arbitrate new rows while locking only a selected history row. */
|
||||||
async (manager) => {
|
async (manager) => {
|
||||||
const repository = manager.getRepository(QqbotMessageSubscription);
|
const repository = manager.getRepository(QqbotMessageSubscription);
|
||||||
const active = await repository.findOne({
|
const active = await repository.findOne({
|
||||||
lock: { mode: 'pessimistic_write' },
|
|
||||||
where: { activeKey: normalized.activeKey, isDeleted: false },
|
where: { activeKey: normalized.activeKey, isDeleted: false },
|
||||||
});
|
});
|
||||||
if (active) this.throwNaturalKeyConflict();
|
if (active) this.throwNaturalKeyConflict();
|
||||||
|
|
||||||
const historical = await repository.findOne({
|
const historicalCandidate = await repository.findOne({
|
||||||
lock: { mode: 'pessimistic_write' },
|
|
||||||
order: { updateTime: 'DESC', id: 'DESC' },
|
order: { updateTime: 'DESC', id: 'DESC' },
|
||||||
where: {
|
where: {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
@ -116,9 +114,15 @@ export class QqbotMessageSubscriptionService {
|
|||||||
sourceKey: normalized.sourceKey,
|
sourceKey: normalized.sourceKey,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (historical) {
|
if (historicalCandidate) {
|
||||||
Object.assign(historical, normalized, { isDeleted: false });
|
const historical = await repository.findOne({
|
||||||
return repository.save(historical);
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { id: historicalCandidate.id, isDeleted: true },
|
||||||
|
});
|
||||||
|
if (historical) {
|
||||||
|
Object.assign(historical, normalized, { isDeleted: false });
|
||||||
|
return repository.save(historical);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return repository.save(
|
return repository.save(
|
||||||
repository.create({ ...normalized, isDeleted: false }),
|
repository.create({ ...normalized, isDeleted: false }),
|
||||||
|
|||||||
@ -210,6 +210,134 @@ function setup(
|
|||||||
return { items, manager, service, source, subscriptionRepository };
|
return { items, manager, service, source, subscriptionRepository };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds two controlled create transactions that reject forbidden range locks with MySQL 1213.
|
||||||
|
* @param historical - Optional deleted candidate both transactions must contend to revive.
|
||||||
|
* @returns Shared rows, the service, and repository call evidence for concurrent-create assertions.
|
||||||
|
*/
|
||||||
|
function setupConcurrentCreateRace(historical?: QqbotMessageSubscription) {
|
||||||
|
const items = historical ? [historical] : [];
|
||||||
|
const bothActiveChecks = deferred();
|
||||||
|
const bothHistoricalLookups = deferred();
|
||||||
|
const contenderRequested = deferred();
|
||||||
|
const historicalRowReleased = deferred();
|
||||||
|
let activeChecks = 0;
|
||||||
|
let historicalLookups = 0;
|
||||||
|
let historicalLockAttempts = 0;
|
||||||
|
let createSequence = 1000;
|
||||||
|
const subscriptionRepository = {
|
||||||
|
create: jest.fn((input) =>
|
||||||
|
subscription({ id: String(createSequence++), ...input }),
|
||||||
|
),
|
||||||
|
findOne: jest.fn(async (options) => {
|
||||||
|
const where = options.where as FindOptionsWhere<QqbotMessageSubscription>;
|
||||||
|
if (where.activeKey !== undefined) {
|
||||||
|
if (options.lock?.mode === 'pessimistic_write') {
|
||||||
|
throw { code: 'ER_LOCK_DEADLOCK', errno: 1213 };
|
||||||
|
}
|
||||||
|
activeChecks += 1;
|
||||||
|
if (activeChecks === 2) bothActiveChecks.resolve();
|
||||||
|
await bothActiveChecks.promise;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
where.sourceConfigDigest !== undefined &&
|
||||||
|
where.sourceKey !== undefined
|
||||||
|
) {
|
||||||
|
if (options.lock?.mode === 'pessimistic_write') {
|
||||||
|
throw { code: 'ER_LOCK_DEADLOCK', errno: 1213 };
|
||||||
|
}
|
||||||
|
historicalLookups += 1;
|
||||||
|
if (historicalLookups === 2) bothHistoricalLookups.resolve();
|
||||||
|
await bothHistoricalLookups.promise;
|
||||||
|
return (
|
||||||
|
items
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.isDeleted &&
|
||||||
|
item.sourceConfigDigest === where.sourceConfigDigest &&
|
||||||
|
item.sourceKey === where.sourceKey,
|
||||||
|
)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const time = String(right.updateTime).localeCompare(
|
||||||
|
String(left.updateTime),
|
||||||
|
);
|
||||||
|
return time || right.id.localeCompare(left.id);
|
||||||
|
})[0] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (where.id !== undefined && where.isDeleted === true) {
|
||||||
|
if (options.lock?.mode !== 'pessimistic_write') {
|
||||||
|
throw new Error('historical candidate must be locked by primary key');
|
||||||
|
}
|
||||||
|
historicalLockAttempts += 1;
|
||||||
|
if (historicalLockAttempts === 1) {
|
||||||
|
await contenderRequested.promise;
|
||||||
|
} else {
|
||||||
|
contenderRequested.resolve();
|
||||||
|
await historicalRowReleased.promise;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
items.find(
|
||||||
|
(item) => item.id === where.id && item.isDeleted === true,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error('unexpected subscription lookup');
|
||||||
|
}),
|
||||||
|
save: jest.fn(async (item: QqbotMessageSubscription) => {
|
||||||
|
const duplicate = items.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.id !== item.id &&
|
||||||
|
!candidate.isDeleted &&
|
||||||
|
candidate.activeKey === item.activeKey,
|
||||||
|
);
|
||||||
|
if (duplicate) throw { code: 'ER_DUP_ENTRY', errno: 1062 };
|
||||||
|
const existing = items.find((candidate) => candidate.id === item.id);
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, item);
|
||||||
|
historicalRowReleased.resolve();
|
||||||
|
} else {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
} as unknown as jest.Mocked<Repository<QqbotMessageSubscription>>;
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity) => {
|
||||||
|
if (entity === QqbotMessageSubscription) return subscriptionRepository;
|
||||||
|
throw new Error('unexpected repository');
|
||||||
|
}),
|
||||||
|
} as unknown as jest.Mocked<EntityManager>;
|
||||||
|
Object.assign(subscriptionRepository, {
|
||||||
|
manager: { transaction: jest.fn((callback) => callback(manager)) },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
service: new QqbotMessageSubscriptionService(
|
||||||
|
subscriptionRepository,
|
||||||
|
registry(adapter()),
|
||||||
|
),
|
||||||
|
subscriptionRepository,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs two same-key creates and returns their success or failure values without short-circuiting. */
|
||||||
|
async function runConcurrentCreates(
|
||||||
|
service: QqbotMessageSubscriptionService,
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
const input = {
|
||||||
|
enabled: true,
|
||||||
|
name: '并发订阅',
|
||||||
|
sourceConfig: CONFIG,
|
||||||
|
sourceKey: SOURCE_KEY,
|
||||||
|
};
|
||||||
|
return Promise.all([
|
||||||
|
service.create(input).catch((error: unknown) => error),
|
||||||
|
service.create(input).catch((error: unknown) => error),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
describe('QqbotMessageSubscriptionService', () => {
|
describe('QqbotMessageSubscriptionService', () => {
|
||||||
it('uses sorted allowlisted config JSON for the active natural key', async () => {
|
it('uses sorted allowlisted config JSON for the active natural key', async () => {
|
||||||
const source = adapter();
|
const source = adapter();
|
||||||
@ -291,6 +419,61 @@ describe('QqbotMessageSubscriptionService', () => {
|
|||||||
).rejects.toThrow('database unavailable');
|
).rejects.toThrow('database unavailable');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets the unique key settle concurrent creates for a previously unseen natural key', async () => {
|
||||||
|
const { items, service, subscriptionRepository } =
|
||||||
|
setupConcurrentCreateRace();
|
||||||
|
|
||||||
|
const results = await runConcurrentCreates(service);
|
||||||
|
const failures = results.filter((result) => result instanceof Error);
|
||||||
|
const successes = results.filter((result) => !(result instanceof Error));
|
||||||
|
|
||||||
|
expect(successes).toHaveLength(1);
|
||||||
|
expect(failures).toHaveLength(1);
|
||||||
|
expect(failures[0]).toBeInstanceOf(HttpException);
|
||||||
|
expect(failures[0]).toMatchObject({ status: HttpStatus.CONFLICT });
|
||||||
|
expect(failures[0]).not.toMatchObject({ errno: 1213 });
|
||||||
|
expect(items.filter((item) => !item.isDeleted)).toHaveLength(1);
|
||||||
|
for (const [options] of subscriptionRepository.findOne.mock.calls) {
|
||||||
|
const where = options.where as FindOptionsWhere<QqbotMessageSubscription>;
|
||||||
|
if (
|
||||||
|
where.activeKey !== undefined ||
|
||||||
|
where.sourceConfigDigest !== undefined
|
||||||
|
) {
|
||||||
|
expect(options.lock).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks only the selected history row before one concurrent revival wins', async () => {
|
||||||
|
const historical = subscription({
|
||||||
|
activeKey: null,
|
||||||
|
id: '102',
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
const { items, service, subscriptionRepository } =
|
||||||
|
setupConcurrentCreateRace(historical);
|
||||||
|
|
||||||
|
const results = await runConcurrentCreates(service);
|
||||||
|
const failures = results.filter((result) => result instanceof Error);
|
||||||
|
const successes = results.filter((result) => !(result instanceof Error));
|
||||||
|
|
||||||
|
expect(successes).toHaveLength(1);
|
||||||
|
expect(failures).toHaveLength(1);
|
||||||
|
expect(failures[0]).toBeInstanceOf(HttpException);
|
||||||
|
expect(failures[0]).toMatchObject({ status: HttpStatus.CONFLICT });
|
||||||
|
expect(failures[0]).not.toMatchObject({ errno: 1213 });
|
||||||
|
expect(items).toEqual([
|
||||||
|
expect.objectContaining({ id: '102', isDeleted: false }),
|
||||||
|
]);
|
||||||
|
const candidateLocks = subscriptionRepository.findOne.mock.calls.filter(
|
||||||
|
([options]) => options.lock?.mode === 'pessimistic_write',
|
||||||
|
);
|
||||||
|
expect(candidateLocks).toHaveLength(2);
|
||||||
|
for (const [options] of candidateLocks) {
|
||||||
|
expect(options.where).toEqual({ id: '102', isDeleted: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('soft deletes safely and revives the newest matching historical row with its original ID', async () => {
|
it('soft deletes safely and revives the newest matching historical row with its original ID', async () => {
|
||||||
const older = subscription({
|
const older = subscription({
|
||||||
activeKey: null,
|
activeKey: null,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user