Skip to content

Commit 5b22c42

Browse files
feature(entitlements): CR fixes
1 parent 5c3bbf3 commit 5b22c42

8 files changed

Lines changed: 120 additions & 49 deletions

File tree

src/clients/entitlements/entitlements-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class EntitlementsClient extends TypedEmitter<IEntitlementsClientEvents>
126126
const entitlementsData = await this.httpClient.get<VendorEntitlementsDto>('/api/v1/vendor-entitlements');
127127
const vendorEntitlementsDto = entitlementsData.data;
128128

129-
const { isUpdated, revision } = await this.cacheManager.loadSnapshotAsCurrent(vendorEntitlementsDto);
129+
const { isUpdated, revision } = await this.cacheManager.loadSnapshotAsCurrentRevision(vendorEntitlementsDto);
130130

131131
if (isUpdated) {
132132
this.emit(EntitlementsClientEventsEnum.SNAPSHOT_UPDATED, revision);
@@ -186,4 +186,4 @@ export class EntitlementsClient extends TypedEmitter<IEntitlementsClientEvents>
186186
destroy(): void {
187187
this.refreshTimeout && clearTimeout(this.refreshTimeout);
188188
}
189-
}
189+
}

src/clients/entitlements/storage/cache.revision-manager.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ describe(CacheRevisionManager.name, () => {
6868
jest.mocked(FronteggEntitlementsCacheInitializer.forLeader).mockResolvedValue(expectedNewEntitlementsCache);
6969

7070
// when
71-
loadingSnapshotResult = await cut.loadSnapshotAsCurrent(getDTO(333));
71+
loadingSnapshotResult = await cut.loadSnapshotAsCurrentRevision(getDTO(333));
7272
});
7373

7474
it('then it resolves to IsUpdatedToRev structure telling with updated revision.', async () => {
@@ -97,7 +97,7 @@ describe(CacheRevisionManager.name, () => {
9797
jest.mocked(FronteggEntitlementsCacheInitializer.forFollower).mockClear();
9898

9999
// when
100-
loadingSnapshotResult = await cut.loadSnapshotAsCurrent(getDTO(1));
100+
loadingSnapshotResult = await cut.loadSnapshotAsCurrentRevision(getDTO(1));
101101
});
102102

103103
it('then it resolves to IsUpdatedToRev structure telling nothing got updated and revision (1).', async () => {

src/clients/entitlements/storage/cache.revision-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class CacheRevisionManager {
1515

1616
constructor(private readonly cache: ICacheManager<CacheValue>) {}
1717

18-
async loadSnapshotAsCurrent(dto: VendorEntitlementsDto): Promise<IsUpdatedToRev> {
18+
async loadSnapshotAsCurrentRevision(dto: VendorEntitlementsDto): Promise<IsUpdatedToRev> {
1919
const currentRevision = await this.getCurrentCacheRevision();
2020
const givenRevision = dto.snapshotOffset;
2121

src/clients/entitlements/storage/dto-to-cache-sources.mapper.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
11
import { FeatureId, VendorEntitlementsDto } from '../types';
22
import { BundlesSource, ExpirationTime, FeatureSource, NO_EXPIRE, UNBUNDLED_SRC_ID } from './types';
33

4+
function ensureMapInMap<K, T extends Map<any, any>>(map: Map<K, T>, mapKey: K): T {
5+
if (!map.has(mapKey)) {
6+
map.set(mapKey, new Map() as T);
7+
}
8+
9+
return map.get(mapKey)!;
10+
}
11+
12+
function ensureArrayInMap<K, T>(map: Map<K, T[]>, mapKey: K): T[] {
13+
if (!map.has(mapKey)) {
14+
map.set(mapKey, []);
15+
}
16+
17+
return map.get(mapKey)!;
18+
}
19+
20+
function parseExpirationTime(time?: string | null): ExpirationTime {
21+
if (time !== undefined && time !== null) {
22+
return new Date(time).getTime();
23+
}
24+
25+
return NO_EXPIRE;
26+
}
27+
428
export class DtoToCacheSourcesMapper {
5-
map(dto: VendorEntitlementsDto): BundlesSource {
29+
static map(dto: VendorEntitlementsDto): BundlesSource {
630
const {
731
data: { features, entitlements, featureBundles },
832
} = dto;
@@ -56,15 +80,15 @@ export class DtoToCacheSourcesMapper {
5680
if (bundle) {
5781
if (userId) {
5882
// that's user-targeted entitlement
59-
const tenantUserEntitlements = this.ensureMapInMap(bundle.user_entitlements, tenantId);
60-
const usersEntitlements = this.ensureArrayInMap(tenantUserEntitlements, userId);
83+
const tenantUserEntitlements = ensureMapInMap(bundle.user_entitlements, tenantId);
84+
const usersEntitlements = ensureArrayInMap(tenantUserEntitlements, userId);
6185

62-
usersEntitlements.push(this.parseExpirationTime(expirationDate));
86+
usersEntitlements.push(parseExpirationTime(expirationDate));
6387
} else {
6488
// that's tenant-targeted entitlement
65-
const tenantEntitlements = this.ensureArrayInMap(bundle.tenant_entitlements, tenantId);
89+
const tenantEntitlements = ensureArrayInMap(bundle.tenant_entitlements, tenantId);
6690

67-
tenantEntitlements.push(this.parseExpirationTime(expirationDate));
91+
tenantEntitlements.push(parseExpirationTime(expirationDate));
6892
}
6993
} else {
7094
// TODO: issue warning here!
@@ -87,28 +111,4 @@ export class DtoToCacheSourcesMapper {
87111

88112
return bundlesMap;
89113
}
90-
91-
private ensureMapInMap<K, T extends Map<any, any>>(map: Map<K, T>, mapKey: K): T {
92-
if (!map.has(mapKey)) {
93-
map.set(mapKey, new Map() as T);
94-
}
95-
96-
return map.get(mapKey)!;
97-
}
98-
99-
private ensureArrayInMap<K, T>(map: Map<K, T[]>, mapKey: K): T[] {
100-
if (!map.has(mapKey)) {
101-
map.set(mapKey, []);
102-
}
103-
104-
return map.get(mapKey)!;
105-
}
106-
107-
private parseExpirationTime(time?: string | null): ExpirationTime {
108-
if (time !== undefined && time !== null) {
109-
return new Date(time).getTime();
110-
}
111-
112-
return NO_EXPIRE;
113-
}
114114
}

src/clients/entitlements/storage/frontegg-cache/frontegg.cache-initializer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class FronteggEntitlementsCacheInitializer {
2525

2626
const cacheInitializer = new FronteggEntitlementsCacheInitializer(entitlementsCache);
2727

28-
const sources = new DtoToCacheSourcesMapper().map(dto);
28+
const sources = DtoToCacheSourcesMapper.map(dto);
2929

3030
await cacheInitializer.setupPermissionsReadModel(sources);
3131
await cacheInitializer.setupEntitlementsReadModel(sources);
@@ -96,7 +96,7 @@ export class FronteggEntitlementsCacheInitializer {
9696
const allPermissions = await cache.collection(PERMISSIONS_COLLECTION_LIST).getAll<string>();
9797

9898
for (const permission of allPermissions) {
99-
await cache.expire([ getPermissionMappingKey(permission)], FronteggEntitlementsCacheInitializer.CLEAR_TTL);
99+
await cache.expire([getPermissionMappingKey(permission)], FronteggEntitlementsCacheInitializer.CLEAR_TTL);
100100
}
101101

102102
// clear static fields

src/components/leader-election/ioredis.lock-handler.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,43 @@ export class IORedisLockHandler implements ILockHandler {
99
private static EXTEND_LEADERSHIP_SCRIPT =
1010
"if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end";
1111

12-
async tryToMaintainTheLock(key: string, value: string, expirationTimeMs: number): Promise<boolean> {
12+
/**
13+
* This method calls the Lua script that prolongs the lock on given `leadershipResourceKey` only, when stored value
14+
* equals to given `instanceIdentifier` and then method resolves to `true`.
15+
*
16+
* When `leadershipResourceKey` doesn't exist, or it has a different value, then the leadership is not prolonged and
17+
* method resolves to `false`.
18+
*
19+
* Using Lua script ensures the atomicity of the whole process. Without it there is no guarantee that other Redis
20+
* client doesn't execute operation on `leadershipResourceKey` in-between `GET` and `PEXPIRE` commands.
21+
*/
22+
async tryToMaintainTheLock(
23+
leadershipResourceKey: string,
24+
instanceIdentifier: string,
25+
expirationTimeMs: number,
26+
): Promise<boolean> {
1327
const extended = await this.redis.eval(
1428
IORedisLockHandler.EXTEND_LEADERSHIP_SCRIPT,
1529
NUM_OF_KEYS_IN_LUA_SCRIPT,
16-
key,
17-
value,
30+
leadershipResourceKey,
31+
instanceIdentifier,
1832
expirationTimeMs,
1933
);
2034

2135
return (extended as number) > 0;
2236
}
2337

24-
async tryToLockLeaderResource(key: string, value: string, expirationTimeMs: number): Promise<boolean> {
25-
return (await this.redis.set(key, value, 'PX', expirationTimeMs, 'NX')) !== null;
38+
/**
39+
* This stores the `instanceIdentifier` value into `leadershipResourceKey` only, when the key doesn't exist. If value
40+
* is stored, then TTL is also set to `expirationTimeMs` and method resolves to `true`.
41+
*
42+
* Otherwise method resolved to `false` and no change to `leadershipResourceKey` is introduced.
43+
*/
44+
async tryToLockLeaderResource(
45+
leadershipResourceKey: string,
46+
instanceIdentifier: string,
47+
expirationTimeMs: number,
48+
): Promise<boolean> {
49+
return (await this.redis.set(leadershipResourceKey, instanceIdentifier, 'PX', expirationTimeMs, 'NX')) !== null;
2650
}
2751
}

src/components/leader-election/redis.lock-handler.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,42 @@ export class RedisLockHandler implements ILockHandler {
77
private static EXTEND_LEADERSHIP_SCRIPT =
88
"if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end";
99

10-
async tryToMaintainTheLock(key: string, value: string, expirationTimeMs: number): Promise<boolean> {
10+
/**
11+
* This method calls the Lua script that prolongs the lock on given `leadershipResourceKey` only, when stored value
12+
* equals to given `instanceIdentifier` and then method resolves to `true`.
13+
*
14+
* When `leadershipResourceKey` doesn't exist, or it has a different value, then the leadership is not prolonged and
15+
* method resolves to `false`.
16+
*
17+
* Using Lua script ensures the atomicity of the whole process. Without it there is no guarantee that other Redis
18+
* client doesn't execute operation on `leadershipResourceKey` in-between `GET` and `PEXPIRE` commands.
19+
*/
20+
async tryToMaintainTheLock(
21+
leadershipResourceKey: string,
22+
instanceIdentifier: string,
23+
expirationTimeMs: number,
24+
): Promise<boolean> {
1125
const extended = await this.redis.EVAL(RedisLockHandler.EXTEND_LEADERSHIP_SCRIPT, {
12-
keys: [key],
13-
arguments: [value, expirationTimeMs.toString()],
26+
keys: [leadershipResourceKey],
27+
arguments: [instanceIdentifier, expirationTimeMs.toString()],
1428
});
1529

1630
return (extended as number) > 0;
1731
}
1832

19-
async tryToLockLeaderResource(key: string, value: string, expirationTimeMs: number): Promise<boolean> {
20-
return (await this.redis.SET(key, value, { PX: expirationTimeMs, NX: true })) !== null;
33+
/**
34+
* This stores the `instanceIdentifier` value into `leadershipResourceKey` only, when the key doesn't exist. If value
35+
* is stored, then TTL is also set to `expirationTimeMs` and method resolves to `true`.
36+
*
37+
* Otherwise method resolved to `false` and no change to `leadershipResourceKey` is introduced.
38+
*/
39+
async tryToLockLeaderResource(
40+
leadershipResourceKey: string,
41+
instanceIdentifier: string,
42+
expirationTimeMs: number,
43+
): Promise<boolean> {
44+
return (
45+
(await this.redis.SET(leadershipResourceKey, instanceIdentifier, { PX: expirationTimeMs, NX: true })) !== null
46+
);
2147
}
2248
}

src/components/leader-election/types.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
export interface ILockHandler {
2-
tryToLockLeaderResource(key: string, value: string, expirationTimeMs: number): Promise<boolean>;
3-
tryToMaintainTheLock(key: string, value: string, expirationTimeMs: number): Promise<boolean>;
2+
/**
3+
* This method is about to lock the `leadershipResourceKey` by writing its `instanceIdentifier` to it. The lock should
4+
* not be permanent, but limited to given `expirationTimeMs`. Then the lock can be kept (extended) by calling
5+
* `tryToMaintainTheLock` method.
6+
*/
7+
tryToLockLeaderResource(
8+
leadershipResourceKey: string,
9+
instanceIdentifier: string,
10+
expirationTimeMs: number,
11+
): Promise<boolean>;
12+
13+
/**
14+
* This method is about to prolong the `leadershipResourceKey` time-to-live only, when the key contains value equal to
15+
* given `instanceIdentifier`. Each instance competing for a leadership role needs to have a unique identifier.
16+
*
17+
* This way we know, that only the leader process can prolong its leadership. If leader dies, for any reason, no other
18+
* process can extend its leadership.
19+
*/
20+
tryToMaintainTheLock(
21+
leadershipResourceKey: string,
22+
instanceIdentifier: string,
23+
expirationTimeMs: number,
24+
): Promise<boolean>;
425
}
526

627
export interface ILeadershipElectionOptions {

0 commit comments

Comments
 (0)