fix: 消除 Keeper 通道切换竞态
This commit is contained in:
parent
71dae3b38e
commit
8ce72c1269
@ -42,6 +42,11 @@ type GroupTransactionResult = {
|
|||||||
group: NetworkPortForwardGroup;
|
group: NetworkPortForwardGroup;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type V1ChannelMutationTarget = {
|
||||||
|
action: string;
|
||||||
|
channelId: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NetworkPortForwardGroupService {
|
export class NetworkPortForwardGroupService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -264,8 +269,32 @@ export class NetworkPortForwardGroupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async enableKeeper(groupId: string) {
|
async enableKeeper(groupId: string) {
|
||||||
|
return this.enableKeeperTarget(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableKeeper(groupId: string) {
|
||||||
|
return this.disableKeeperTarget(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enableKeeperV1(channelId: string) {
|
||||||
|
return this.enableKeeperTarget({ action: 'UDP Keeper', channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableKeeperV1(channelId: string) {
|
||||||
|
return this.disableKeeperTarget({ action: 'UDP Keeper', channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async probeV1(channelId: string) {
|
||||||
|
return this.probeTarget({ action: 'UDP Keeper', channelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async probe(groupId: string) {
|
||||||
|
return this.probeTarget(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enableKeeperTarget(target: string | V1ChannelMutationTarget) {
|
||||||
return this.mutateChannel(
|
return this.mutateChannel(
|
||||||
groupId,
|
target,
|
||||||
'udp',
|
'udp',
|
||||||
async (_, channel, channels) => {
|
async (_, channel, channels) => {
|
||||||
if (channel.keeperDesiredEnabled) return false;
|
if (channel.keeperDesiredEnabled) return false;
|
||||||
@ -280,9 +309,9 @@ export class NetworkPortForwardGroupService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async disableKeeper(groupId: string) {
|
private disableKeeperTarget(target: string | V1ChannelMutationTarget) {
|
||||||
return this.mutateChannel(
|
return this.mutateChannel(
|
||||||
groupId,
|
target,
|
||||||
'udp',
|
'udp',
|
||||||
async (_, channel, channels) => {
|
async (_, channel, channels) => {
|
||||||
if (!channel.keeperDesiredEnabled) return false;
|
if (!channel.keeperDesiredEnabled) return false;
|
||||||
@ -298,27 +327,9 @@ export class NetworkPortForwardGroupService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableKeeperV1(channelId: string) {
|
private probeTarget(target: string | V1ChannelMutationTarget) {
|
||||||
return this.enableKeeper(
|
|
||||||
await this.resolveV1GroupId(channelId, 'udp', 'UDP Keeper'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async disableKeeperV1(channelId: string) {
|
|
||||||
return this.disableKeeper(
|
|
||||||
await this.resolveV1GroupId(channelId, 'udp', 'UDP Keeper'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async probeV1(channelId: string) {
|
|
||||||
return this.probe(
|
|
||||||
await this.resolveV1GroupId(channelId, 'udp', 'UDP Keeper'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async probe(groupId: string) {
|
|
||||||
return this.mutateChannel(
|
return this.mutateChannel(
|
||||||
groupId,
|
target,
|
||||||
'udp',
|
'udp',
|
||||||
async (_, channel, channels) => {
|
async (_, channel, channels) => {
|
||||||
this.assertMechanismTransitionAllowed(channels);
|
this.assertMechanismTransitionAllowed(channels);
|
||||||
@ -590,7 +601,7 @@ export class NetworkPortForwardGroupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async mutateChannel(
|
private async mutateChannel(
|
||||||
groupId: string,
|
target: string | V1ChannelMutationTarget,
|
||||||
protocol: PortForwardProtocol,
|
protocol: PortForwardProtocol,
|
||||||
change: (
|
change: (
|
||||||
group: NetworkPortForwardGroup,
|
group: NetworkPortForwardGroup,
|
||||||
@ -599,12 +610,46 @@ export class NetworkPortForwardGroupService {
|
|||||||
) => Promise<boolean>,
|
) => Promise<boolean>,
|
||||||
invalidProtocolIsBadRequest = false,
|
invalidProtocolIsBadRequest = false,
|
||||||
) {
|
) {
|
||||||
this.assertId(groupId, '逻辑组');
|
const v1Target = typeof target === 'string' ? null : target;
|
||||||
|
const targetId = typeof target === 'string' ? target : target.channelId;
|
||||||
|
this.assertId(targetId, v1Target ? '端口转发' : '逻辑组');
|
||||||
const result = await this.dataSource.transaction(async (manager) => {
|
const result = await this.dataSource.transaction(async (manager) => {
|
||||||
const state = await this.lockAgentState(manager);
|
const state = await this.lockAgentState(manager);
|
||||||
|
const mappingRepository = manager.getRepository(NetworkPortForward);
|
||||||
|
const requested = v1Target
|
||||||
|
? await mappingRepository.findOne({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { id: v1Target.channelId, isDeleted: false },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (v1Target && !requested) {
|
||||||
|
throwVbenError('端口转发不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (v1Target && requested && requested.protocol !== protocol) {
|
||||||
|
throwVbenError(
|
||||||
|
`${requested.protocol.toUpperCase()} 通道不支持 ${v1Target.action}`,
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const groupId = requested ? String(requested.groupId) : targetId;
|
||||||
const group = await this.findLockedGroup(manager, groupId);
|
const group = await this.findLockedGroup(manager, groupId);
|
||||||
const channels = await this.findChannels(manager, groupId);
|
const channels = await this.findChannels(manager, groupId);
|
||||||
const channel = channels.find((item) => item.protocol === protocol);
|
const requestedIndex = requested
|
||||||
|
? channels.findIndex((item) => item.id === requested.id)
|
||||||
|
: -1;
|
||||||
|
if (
|
||||||
|
requested &&
|
||||||
|
(requested.isDeleted ||
|
||||||
|
requested.groupId !== group.id ||
|
||||||
|
requestedIndex < 0 ||
|
||||||
|
channels[requestedIndex].groupId !== group.id ||
|
||||||
|
channels[requestedIndex].protocol !== protocol)
|
||||||
|
) {
|
||||||
|
throwVbenError('端口转发通道已失效或被替换', HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
if (requested) channels[requestedIndex] = requested;
|
||||||
|
const channel =
|
||||||
|
requested || channels.find((item) => item.protocol === protocol);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
throwVbenError(
|
throwVbenError(
|
||||||
`逻辑组不包含 ${protocol.toUpperCase()} 协议通道`,
|
`逻辑组不包含 ${protocol.toUpperCase()} 协议通道`,
|
||||||
@ -620,7 +665,7 @@ export class NetworkPortForwardGroupService {
|
|||||||
if (!changed) return { changed, channel };
|
if (!changed) return { changed, channel };
|
||||||
const revision = this.advanceGlobalRevision(state);
|
const revision = this.advanceGlobalRevision(state);
|
||||||
this.assignRevision([channel], revision, state.desiredIssuedAt);
|
this.assignRevision([channel], revision, state.desiredIssuedAt);
|
||||||
await manager.getRepository(NetworkPortForward).save(channel);
|
await mappingRepository.save(channel);
|
||||||
await manager.getRepository(NetworkAgentState).save(state);
|
await manager.getRepository(NetworkAgentState).save(state);
|
||||||
return { changed, channel };
|
return { changed, channel };
|
||||||
});
|
});
|
||||||
@ -640,25 +685,6 @@ export class NetworkPortForwardGroupService {
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveV1GroupId(
|
|
||||||
channelId: string,
|
|
||||||
protocol: PortForwardProtocol,
|
|
||||||
action: string,
|
|
||||||
): Promise<string> {
|
|
||||||
this.assertId(channelId, '端口转发');
|
|
||||||
const channel = await this.mappingRepository.findOne({
|
|
||||||
where: { id: channelId, isDeleted: false },
|
|
||||||
});
|
|
||||||
if (!channel) throwVbenError('端口转发不存在', HttpStatus.NOT_FOUND);
|
|
||||||
if (channel.protocol !== protocol) {
|
|
||||||
throwVbenError(
|
|
||||||
`${channel.protocol.toUpperCase()} 通道不支持 ${action}`,
|
|
||||||
HttpStatus.BAD_REQUEST,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return String(channel.groupId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findChannels(
|
private async findChannels(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
|
|||||||
@ -16,13 +16,16 @@ type Harness = {
|
|||||||
bootstrapExecute: jest.Mock;
|
bootstrapExecute: jest.Mock;
|
||||||
groups: NetworkPortForwardGroup[];
|
groups: NetworkPortForwardGroup[];
|
||||||
histories: NetworkEndpointHistory[];
|
histories: NetworkEndpointHistory[];
|
||||||
|
injectedMappingFindOne: jest.Mock;
|
||||||
mappings: NetworkPortForward[];
|
mappings: NetworkPortForward[];
|
||||||
mqtt: jest.Mocked<Pick<NetworkAgentMqttService, 'requestDesiredPublish'>>;
|
mqtt: jest.Mocked<Pick<NetworkAgentMqttService, 'requestDesiredPublish'>>;
|
||||||
service: NetworkManagementService;
|
service: NetworkManagementService;
|
||||||
state: NetworkAgentState;
|
state: NetworkAgentState;
|
||||||
|
transactionalMappingFindOne: jest.Mock;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReleaseConfig = {
|
type ReleaseConfig = {
|
||||||
|
beforeTransaction?: () => void;
|
||||||
canaryPorts?: string;
|
canaryPorts?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
};
|
};
|
||||||
@ -56,6 +59,12 @@ function createHarness(
|
|||||||
bootstrapOrder.push('insert-ignore');
|
bootstrapOrder.push('insert-ignore');
|
||||||
return { identifiers: [] };
|
return { identifiers: [] };
|
||||||
});
|
});
|
||||||
|
const findMapping = async ({ where }: { where: Record<string, unknown> }) =>
|
||||||
|
mappings.find((mapping) =>
|
||||||
|
Object.entries(where).every(([key, value]) => mapping[key] === value),
|
||||||
|
) || null;
|
||||||
|
const injectedMappingFindOne = jest.fn(findMapping);
|
||||||
|
const transactionalMappingFindOne = jest.fn(findMapping);
|
||||||
const state = Object.assign(new NetworkAgentState(), {
|
const state = Object.assign(new NetworkAgentState(), {
|
||||||
agentId: 'nas-main',
|
agentId: 'nas-main',
|
||||||
appliedRevision: '0',
|
appliedRevision: '0',
|
||||||
@ -74,10 +83,7 @@ function createHarness(
|
|||||||
mappings.filter((mapping) =>
|
mappings.filter((mapping) =>
|
||||||
Object.entries(where).every(([key, value]) => mapping[key] === value),
|
Object.entries(where).every(([key, value]) => mapping[key] === value),
|
||||||
),
|
),
|
||||||
findOne: async ({ where }) =>
|
findOne: injectedMappingFindOne,
|
||||||
mappings.find((mapping) =>
|
|
||||||
Object.entries(where).every(([key, value]) => mapping[key] === value),
|
|
||||||
) || null,
|
|
||||||
save: async (value) => {
|
save: async (value) => {
|
||||||
const values = Array.isArray(value) ? value : [value];
|
const values = Array.isArray(value) ? value : [value];
|
||||||
for (const mapping of values) {
|
for (const mapping of values) {
|
||||||
@ -88,6 +94,10 @@ function createHarness(
|
|||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
} as unknown as Repository<NetworkPortForward>;
|
} as unknown as Repository<NetworkPortForward>;
|
||||||
|
const transactionalMappingRepository = {
|
||||||
|
...mappingRepository,
|
||||||
|
findOne: transactionalMappingFindOne,
|
||||||
|
} as unknown as Repository<NetworkPortForward>;
|
||||||
const groupRepository = {
|
const groupRepository = {
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
Object.assign(new NetworkPortForwardGroup(), { id: '200' }, input),
|
Object.assign(new NetworkPortForwardGroup(), { id: '200' }, input),
|
||||||
@ -128,7 +138,7 @@ function createHarness(
|
|||||||
} as unknown as Repository<NetworkEndpointHistory>;
|
} as unknown as Repository<NetworkEndpointHistory>;
|
||||||
const manager = {
|
const manager = {
|
||||||
getRepository: (entity) => {
|
getRepository: (entity) => {
|
||||||
if (entity === NetworkPortForward) return mappingRepository;
|
if (entity === NetworkPortForward) return transactionalMappingRepository;
|
||||||
if (entity === NetworkPortForwardGroup) return groupRepository;
|
if (entity === NetworkPortForwardGroup) return groupRepository;
|
||||||
if (entity === NetworkAgentState) return stateRepository;
|
if (entity === NetworkAgentState) return stateRepository;
|
||||||
if (entity === NetworkEndpointHistory) return historyRepository;
|
if (entity === NetworkEndpointHistory) return historyRepository;
|
||||||
@ -136,7 +146,10 @@ function createHarness(
|
|||||||
},
|
},
|
||||||
} as unknown as EntityManager;
|
} as unknown as EntityManager;
|
||||||
const dataSource = {
|
const dataSource = {
|
||||||
transaction: async (work) => await work(manager),
|
transaction: async (work) => {
|
||||||
|
releaseConfig.beforeTransaction?.();
|
||||||
|
return await work(manager);
|
||||||
|
},
|
||||||
} as unknown as DataSource;
|
} as unknown as DataSource;
|
||||||
const configService = {
|
const configService = {
|
||||||
get: (key) =>
|
get: (key) =>
|
||||||
@ -173,10 +186,12 @@ function createHarness(
|
|||||||
bootstrapOrder,
|
bootstrapOrder,
|
||||||
groups,
|
groups,
|
||||||
histories,
|
histories,
|
||||||
|
injectedMappingFindOne,
|
||||||
mappings,
|
mappings,
|
||||||
mqtt,
|
mqtt,
|
||||||
service,
|
service,
|
||||||
state,
|
state,
|
||||||
|
transactionalMappingFindOne,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -674,4 +689,35 @@ describe('NetworkManagementService', () => {
|
|||||||
status: 404,
|
status: 404,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('locks the exact v1 channel ID in-transaction and never mutates its replacement', async () => {
|
||||||
|
const stale = createMapping({ id: '100' });
|
||||||
|
const replacement = createMapping({ id: '102', probeRequestId: null });
|
||||||
|
const mappings = [stale];
|
||||||
|
const harness = createHarness(mappings, {
|
||||||
|
beforeTransaction: () => {
|
||||||
|
stale.activeGroupProtocolKey = null;
|
||||||
|
stale.activeKey = null;
|
||||||
|
stale.isDeleted = true;
|
||||||
|
mappings.push(replacement);
|
||||||
|
},
|
||||||
|
mode: 'off',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(harness.service.enableKeeper('100')).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
expect(harness.transactionalMappingFindOne).toHaveBeenCalledWith({
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
where: { id: '100', isDeleted: false },
|
||||||
|
});
|
||||||
|
expect(harness.injectedMappingFindOne).not.toHaveBeenCalled();
|
||||||
|
expect(replacement).toMatchObject({
|
||||||
|
desiredRevision: '3',
|
||||||
|
keeperDesiredEnabled: false,
|
||||||
|
probeRequestId: null,
|
||||||
|
});
|
||||||
|
expect(harness.state.desiredRevision).toBe('3');
|
||||||
|
expect(harness.mqtt.requestDesiredPublish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user