| | import { expect } from '@playwright/test'; |
| |
|
| | describe('LeaderElection with Redis', () => { |
| | let LeaderElection: typeof import('../LeaderElection').LeaderElection; |
| | let instances: InstanceType<typeof import('../LeaderElection').LeaderElection>[] = []; |
| | let keyvRedisClient: Awaited<typeof import('~/cache/redisClients')>['keyvRedisClient']; |
| | let ioredisClient: Awaited<typeof import('~/cache/redisClients')>['ioredisClient']; |
| |
|
| | beforeAll(async () => { |
| | |
| | process.env.USE_REDIS = 'true'; |
| | process.env.REDIS_URI = process.env.REDIS_URI ?? 'redis://127.0.0.1:6379'; |
| | process.env.REDIS_KEY_PREFIX = 'LeaderElection-IntegrationTest'; |
| |
|
| | |
| | const leaderElectionModule = await import('../LeaderElection'); |
| | const redisClients = await import('~/cache/redisClients'); |
| |
|
| | LeaderElection = leaderElectionModule.LeaderElection; |
| | keyvRedisClient = redisClients.keyvRedisClient; |
| | ioredisClient = redisClients.ioredisClient; |
| |
|
| | |
| | if (!keyvRedisClient) { |
| | throw new Error('Redis client is not initialized'); |
| | } |
| |
|
| | |
| | await redisClients.keyvRedisClientReady; |
| |
|
| | |
| | process.setMaxListeners(200); |
| | }); |
| |
|
| | afterEach(async () => { |
| | await Promise.all(instances.map((instance) => instance.resign())); |
| | instances = []; |
| |
|
| | |
| | if (keyvRedisClient) { |
| | await keyvRedisClient.del(LeaderElection.LEADER_KEY); |
| | } |
| | }); |
| |
|
| | afterAll(async () => { |
| | |
| | if (keyvRedisClient?.isOpen) await keyvRedisClient.disconnect(); |
| | if (ioredisClient?.status === 'ready') await ioredisClient.quit(); |
| | }); |
| |
|
| | describe('Test Case 1: Simulate shutdown of the leader', () => { |
| | it('should elect a new leader after the current leader resigns', async () => { |
| | |
| | instances = Array.from({ length: 100 }, () => new LeaderElection()); |
| |
|
| | |
| | const resultsWithInstances = await Promise.all( |
| | instances.map(async (instance) => ({ |
| | instance, |
| | isLeader: await instance.isLeader(), |
| | })), |
| | ); |
| |
|
| | |
| | const leaders = resultsWithInstances.filter((r) => r.isLeader); |
| | const followers = resultsWithInstances.filter((r) => !r.isLeader); |
| | const leader = leaders[0].instance; |
| | const nextLeader = followers[0].instance; |
| |
|
| | |
| | expect(leaders.length).toBe(1); |
| |
|
| | |
| | expect(await LeaderElection.getLeaderUUID()).toBe(leader.UUID); |
| |
|
| | |
| | await leader.resign(); |
| |
|
| | |
| | expect(await LeaderElection.getLeaderUUID()).toBeNull(); |
| |
|
| | |
| | expect(await nextLeader.isLeader()).toBe(true); |
| | }, 30000); |
| | }); |
| |
|
| | describe('Test Case 2: Simulate crash of the leader', () => { |
| | it('should allow re-election after leader crashes (lease expires)', async () => { |
| | |
| | const clusterConfigModule = await import('../config'); |
| | const originalConfig = { ...clusterConfigModule.clusterConfig }; |
| |
|
| | |
| | Object.assign(clusterConfigModule.clusterConfig, { |
| | LEADER_LEASE_DURATION: 2, |
| | LEADER_RENEW_INTERVAL: 4, |
| | }); |
| |
|
| | try { |
| | |
| | const instance = new LeaderElection(); |
| | instances.push(instance); |
| |
|
| | |
| | expect(await instance.isLeader()).toBe(true); |
| |
|
| | |
| | expect(await LeaderElection.getLeaderUUID()).toBe(instance.UUID); |
| |
|
| | |
| | instance.clearRefreshTimer(); |
| |
|
| | |
| | expect(await LeaderElection.getLeaderUUID()).toBe(instance.UUID); |
| | expect(await instance.isLeader()).toBe(false); |
| |
|
| | |
| | await new Promise((resolve) => setTimeout(resolve, 3000)); |
| |
|
| | |
| | expect(await LeaderElection.getLeaderUUID()).toBeNull(); |
| | } finally { |
| | |
| | Object.assign(clusterConfigModule.clusterConfig, originalConfig); |
| | } |
| | }, 15000); |
| | }); |
| |
|
| | describe('Test Case 3: Stress testing', () => { |
| | it('should ensure only one instance becomes leader even when multiple instances call electSelf() at once', async () => { |
| | |
| | instances = Array.from({ length: 10 }, () => new LeaderElection()); |
| |
|
| | |
| | const results = await Promise.all(instances.map((instance) => instance['electSelf']())); |
| |
|
| | |
| | const successCount = results.filter((success) => success).length; |
| | expect(successCount).toBe(1); |
| |
|
| | |
| | const winnerInstance = instances.find((_, index) => results[index]); |
| |
|
| | |
| | expect(await LeaderElection.getLeaderUUID()).toBe(winnerInstance?.UUID); |
| | }, 15000); |
| | }); |
| | }); |
| |
|
| | describe('LeaderElection without Redis', () => { |
| | let LeaderElection: typeof import('../LeaderElection').LeaderElection; |
| | let instances: InstanceType<typeof import('../LeaderElection').LeaderElection>[] = []; |
| |
|
| | beforeAll(async () => { |
| | |
| | process.env.USE_REDIS = 'false'; |
| |
|
| | |
| | jest.resetModules(); |
| |
|
| | |
| | const leaderElectionModule = await import('../LeaderElection'); |
| | LeaderElection = leaderElectionModule.LeaderElection; |
| | }); |
| |
|
| | afterEach(async () => { |
| | await Promise.all(instances.map((instance) => instance.resign())); |
| | instances = []; |
| | }); |
| |
|
| | afterAll(() => { |
| | |
| | process.env.USE_REDIS = 'true'; |
| |
|
| | |
| | jest.resetModules(); |
| | }); |
| |
|
| | it('should allow all instances to be leaders when USE_REDIS is false', async () => { |
| | |
| | instances = Array.from({ length: 10 }, () => new LeaderElection()); |
| |
|
| | |
| | const results = await Promise.all(instances.map((instance) => instance.isLeader())); |
| |
|
| | |
| | expect(results.every((isLeader) => isLeader)).toBe(true); |
| | expect(results.filter((isLeader) => isLeader).length).toBe(10); |
| | }); |
| |
|
| | it('should return null for getLeaderUUID when USE_REDIS is false', async () => { |
| | |
| | instances = Array.from({ length: 3 }, () => new LeaderElection()); |
| |
|
| | |
| | await Promise.all(instances.map((instance) => instance.isLeader())); |
| |
|
| | |
| | expect(await LeaderElection.getLeaderUUID()).toBeNull(); |
| | }); |
| |
|
| | it('should allow resign() to be called without throwing errors', async () => { |
| | |
| | instances = Array.from({ length: 5 }, () => new LeaderElection()); |
| |
|
| | |
| | await Promise.all(instances.map((instance) => instance.isLeader())); |
| |
|
| | |
| | await expect( |
| | Promise.all(instances.map((instance) => instance.resign())), |
| | ).resolves.not.toThrow(); |
| |
|
| | |
| | const results = await Promise.all(instances.map((instance) => instance.isLeader())); |
| | expect(results.every((isLeader) => isLeader)).toBe(true); |
| | }); |
| | }); |
| |
|