From dc5dd9ac45bcd3de57e9fc30a9b574eca823104f Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Thu, 5 Dec 2024 15:06:46 +0100 Subject: [PATCH 01/11] Implement Retry Wrapper --- src/core/ldap/domain/ldap-client.service.ts | 320 +++++++++++++------- 1 file changed, 210 insertions(+), 110 deletions(-) diff --git a/src/core/ldap/domain/ldap-client.service.ts b/src/core/ldap/domain/ldap-client.service.ts index 3f52e81f5..c8dc47753 100644 --- a/src/core/ldap/domain/ldap-client.service.ts +++ b/src/core/ldap/domain/ldap-client.service.ts @@ -27,6 +27,10 @@ export type PersonData = { @Injectable() export class LdapClientService { + public static readonly DEFAULT_RETRIES: number = 5; + + public static readonly EXPONENTIAL_BACKOFF_FACTOR: number = 3; + public static readonly OEFFENTLICHE_SCHULEN_DOMAIN_DEFAULT: string = 'schule-sh.de'; public static readonly ERSATZ_SCHULEN_DOMAIN_DEFAULT: string = 'ersatzschule-sh.de'; @@ -61,6 +65,57 @@ export class LdapClientService { this.mutex = new Mutex(); } + //** BELOW ONLY PUBLIC FUNCTIONS - MUST USE THE 'executeWithRetry' WRAPPER TO HAVE STRONG FAULT TOLERANCE*/ + + public async createLehrer(person: PersonData, domain: string, mail?: string): Promise> { + return this.executeWithRetry( + () => this.createLehrerInternal(person, domain, mail), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async changeEmailAddressByPersonId(personId: PersonID, newEmailAddress: string): Promise> { + return this.executeWithRetry( + () => this.changeEmailAddressByPersonIdInternal(personId, newEmailAddress), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async modifyPersonAttributes( + oldReferrer: string, + newGivenName?: string, + newSn?: string, + newUid?: string, + ): Promise> { + return this.executeWithRetry( + () => this.modifyPersonAttributesInternal(oldReferrer, newGivenName, newSn, newUid), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async deleteLehrerByReferrer(referrer: string): Promise> { + return this.executeWithRetry( + () => this.deleteLehrerByReferrerInternal(referrer), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async deleteLehrer(person: PersonData, domain: string): Promise> { + return this.executeWithRetry( + () => this.deleteLehrerInternal(person, domain), + LdapClientService.DEFAULT_RETRIES, + ); + } + + public async isLehrerExisting(referrer: string, domain: string): Promise> { + return this.executeWithRetry( + () => this.isLehrerExistingInternal(referrer, domain), + LdapClientService.DEFAULT_RETRIES, + ); + } + + //** BELOW ONLY PRIVATE FUNCTIONS */ + private async bind(): Promise> { this.logger.info('LDAP: bind'); try { @@ -121,26 +176,12 @@ export class LdapClientService { return person?.referrer; } - public async createLehrer( - person: PersonData, - domain: string, - mail?: string, //Wird hier erstmal seperat mit reingegeben bis die Umstellung auf primary/alternative erfolgt - ): Promise> { - const referrer: string | undefined = person.referrer; - if (!referrer) { - return { - ok: false, - error: new UsernameRequiredError( - `Lehrer ${person.vorname} ${person.familienname} does not have a username`, - ), - }; - } + private async isLehrerExistingInternal(referrer: string, domain: string): Promise> { const rootName: Result = this.getRootNameOrError(domain); if (!rootName.ok) return rootName; - const lehrerUid: string = this.getLehrerUid(referrer, rootName.value); return this.mutex.runExclusive(async () => { - this.logger.info('LDAP: createLehrer'); + this.logger.info('LDAP: isLehrerExisting'); const client: Client = this.ldapClient.getClient(); const bindResult: Result = await this.bind(); if (!bindResult.ok) return bindResult; @@ -148,71 +189,66 @@ export class LdapClientService { const searchResultLehrer: SearchResult = await client.search( `ou=${rootName.value},${LdapClientService.DC_SCHULE_SH_DC_DE}`, { - filter: `(uid=${person.referrer})`, + filter: `(uid=${referrer})`, }, ); if (searchResultLehrer.searchEntries.length > 0) { - this.logger.info(`LDAP: Lehrer ${lehrerUid} exists, nothing to create`); - - return { ok: true, value: person }; - } - const entry: LdapPersonEntry = { - uid: referrer, - uidNumber: LdapClientService.UID_NUMBER, - gidNumber: LdapClientService.GID_NUMBER, - homeDirectory: LdapClientService.HOME_DIRECTORY, - cn: referrer, - givenName: person.vorname, - sn: person.familienname, - objectclass: ['inetOrgPerson', 'univentionMail', 'posixAccount'], - mailPrimaryAddress: mail ?? ``, - mailAlternativeAddress: mail ?? ``, - }; - - const controls: Control[] = []; - if (person.ldapEntryUUID) { - entry.entryUUID = person.ldapEntryUUID; - controls.push(new Control(LdapClientService.RELAX_OID)); + return { ok: true, value: true }; } + return { ok: true, value: false }; + }); + } - try { - await client.add(lehrerUid, entry, controls); - this.logger.info(`LDAP: Successfully created lehrer ${lehrerUid}`); - - return { ok: true, value: person }; - } catch (err) { - const errMsg: string = JSON.stringify(err); - this.logger.error(`LDAP: Creating lehrer FAILED, uid:${lehrerUid}, errMsg:${errMsg}`); + private async deleteLehrerByReferrerInternal(referrer: string): Promise> { + return this.mutex.runExclusive(async () => { + this.logger.info('LDAP: deleteLehrer'); + const client: Client = this.ldapClient.getClient(); + const bindResult: Result = await this.bind(); + if (!bindResult.ok) return bindResult; - return { ok: false, error: new LdapCreateLehrerError() }; + const searchResultLehrer: SearchResult = await client.search(`${LdapClientService.DC_SCHULE_SH_DC_DE}`, { + scope: 'sub', + filter: `(uid=${referrer})`, + }); + if (!searchResultLehrer.searchEntries[0]) { + return { + ok: false, + error: new LdapSearchError(LdapEntityType.LEHRER), + }; } + await client.del(searchResultLehrer.searchEntries[0].dn); + this.logger.info(`LDAP: Successfully deleted lehrer by person:${referrer}`); + + return { ok: true, value: referrer }; }); } - public async isLehrerExisting(referrer: string, domain: string): Promise> { + private async deleteLehrerInternal(person: PersonData, domain: string): Promise> { const rootName: Result = this.getRootNameOrError(domain); if (!rootName.ok) return rootName; return this.mutex.runExclusive(async () => { - this.logger.info('LDAP: isLehrerExisting'); + this.logger.info('LDAP: deleteLehrer'); const client: Client = this.ldapClient.getClient(); const bindResult: Result = await this.bind(); if (!bindResult.ok) return bindResult; - - const searchResultLehrer: SearchResult = await client.search( - `ou=${rootName.value},${LdapClientService.DC_SCHULE_SH_DC_DE}`, - { - filter: `(uid=${referrer})`, - }, - ); - if (searchResultLehrer.searchEntries.length > 0) { - return { ok: true, value: true }; + if (!person.referrer) { + return { + ok: false, + error: new UsernameRequiredError( + `Lehrer ${person.vorname} ${person.familienname} does not have a username`, + ), + }; } - return { ok: true, value: false }; + const lehrerUid: string = this.getLehrerUid(person.referrer, rootName.value); + await client.del(lehrerUid); + this.logger.info(`LDAP: Successfully deleted lehrer ${lehrerUid}`); + + return { ok: true, value: person }; }); } - public async modifyPersonAttributes( + private async modifyPersonAttributesInternal( oldReferrer: string, newGivenName?: string, newSn?: string, @@ -291,56 +327,10 @@ export class LdapClientService { }); } - public async deleteLehrerByReferrer(referrer: string): Promise> { - return this.mutex.runExclusive(async () => { - this.logger.info('LDAP: deleteLehrer'); - const client: Client = this.ldapClient.getClient(); - const bindResult: Result = await this.bind(); - if (!bindResult.ok) return bindResult; - - const searchResultLehrer: SearchResult = await client.search(`${LdapClientService.DC_SCHULE_SH_DC_DE}`, { - scope: 'sub', - filter: `(uid=${referrer})`, - }); - if (!searchResultLehrer.searchEntries[0]) { - return { - ok: false, - error: new LdapSearchError(LdapEntityType.LEHRER), - }; - } - await client.del(searchResultLehrer.searchEntries[0].dn); - this.logger.info(`LDAP: Successfully deleted lehrer by person:${referrer}`); - - return { ok: true, value: referrer }; - }); - } - - public async deleteLehrer(person: PersonData, domain: string): Promise> { - const rootName: Result = this.getRootNameOrError(domain); - if (!rootName.ok) return rootName; - - return this.mutex.runExclusive(async () => { - this.logger.info('LDAP: deleteLehrer'); - const client: Client = this.ldapClient.getClient(); - const bindResult: Result = await this.bind(); - if (!bindResult.ok) return bindResult; - if (!person.referrer) { - return { - ok: false, - error: new UsernameRequiredError( - `Lehrer ${person.vorname} ${person.familienname} does not have a username`, - ), - }; - } - const lehrerUid: string = this.getLehrerUid(person.referrer, rootName.value); - await client.del(lehrerUid); - this.logger.info(`LDAP: Successfully deleted lehrer ${lehrerUid}`); - - return { ok: true, value: person }; - }); - } - - public async changeEmailAddressByPersonId(personId: PersonID, newEmailAddress: string): Promise> { + private async changeEmailAddressByPersonIdInternal( + personId: PersonID, + newEmailAddress: string, + ): Promise> { const referrer: string | undefined = await this.getPersonReferrerOrUndefined(personId); if (!referrer) { this.logger.error( @@ -434,4 +424,114 @@ export class LdapClientService { } }); } + + private async createLehrerInternal( + person: PersonData, + domain: string, + mail?: string, //Wird hier erstmal seperat mit reingegeben bis die Umstellung auf primary/alternative erfolgt + ): Promise> { + const referrer: string | undefined = person.referrer; + if (!referrer) { + return { + ok: false, + error: new UsernameRequiredError( + `Lehrer ${person.vorname} ${person.familienname} does not have a username`, + ), + }; + } + const rootName: Result = this.getRootNameOrError(domain); + if (!rootName.ok) return rootName; + + const lehrerUid: string = this.getLehrerUid(referrer, rootName.value); + return this.mutex.runExclusive(async () => { + this.logger.info('LDAP: createLehrer'); + const client: Client = this.ldapClient.getClient(); + const bindResult: Result = await this.bind(); + if (!bindResult.ok) return bindResult; + + const searchResultLehrer: SearchResult = await client.search( + `ou=${rootName.value},${LdapClientService.DC_SCHULE_SH_DC_DE}`, + { + filter: `(uid=${person.referrer})`, + }, + ); + if (searchResultLehrer.searchEntries.length > 0) { + this.logger.info(`LDAP: Lehrer ${lehrerUid} exists, nothing to create`); + + return { ok: true, value: person }; + } + const entry: LdapPersonEntry = { + uid: referrer, + uidNumber: LdapClientService.UID_NUMBER, + gidNumber: LdapClientService.GID_NUMBER, + homeDirectory: LdapClientService.HOME_DIRECTORY, + cn: referrer, + givenName: person.vorname, + sn: person.familienname, + objectclass: ['inetOrgPerson', 'univentionMail', 'posixAccount'], + mailPrimaryAddress: mail ?? ``, + mailAlternativeAddress: mail ?? ``, + }; + + const controls: Control[] = []; + if (person.ldapEntryUUID) { + entry.entryUUID = person.ldapEntryUUID; + controls.push(new Control(LdapClientService.RELAX_OID)); + } + + try { + await client.add(lehrerUid, entry, controls); + this.logger.info(`LDAP: Successfully created lehrer ${lehrerUid}`); + + return { ok: true, value: person }; + } catch (err) { + const errMsg: string = JSON.stringify(err); + this.logger.error(`LDAP: Creating lehrer FAILED, uid:${lehrerUid}, errMsg:${errMsg}`); + + return { ok: false, error: new LdapCreateLehrerError() }; + } + }); + } + + private executeWithRetry( + func: () => Promise>, + retries: number, + delay: number = 1000, + ): Promise> { + const attempt = (remainingRetries: number, currentDelay: number): Promise> => { + const currentAttempt: number = retries - remainingRetries + 1; + return func() + .then((result: Result) => { + if (result.ok) { + return result; + } else { + throw new Error(`Function returned error: ${result.error.message}`); + } + }) + .catch((error: Error) => { + this.logger.error(`Attempt ${currentAttempt}: Failed with error: ${error.message}`); + if (remainingRetries === 0) { + this.logger.error(`All ${retries} attempts failed. Exiting with failure.`); + return { + ok: false, + error: new Error('Maximum retries reached without success.'), + }; + } + this.logger.warning( + `Attempt ${currentAttempt} failed. Retrying in ${currentDelay}ms... Remaining retries: ${ + remainingRetries - 1 + }`, + ); + return this.sleep(currentDelay).then( + () => attempt(remainingRetries - 1, currentDelay * LdapClientService.EXPONENTIAL_BACKOFF_FACTOR), // Exponential backoff + ); + }); + }; + + return attempt(retries, delay); + } + + private async sleep(ms: number): Promise { + return new Promise((resolve: () => void) => setTimeout(resolve, ms)); + } } From 2b31c9f4cebc6216110cb5650b56e0b384d419d4 Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Sun, 8 Dec 2024 09:07:05 +0100 Subject: [PATCH 02/11] Lint File Fix --- src/core/ldap/domain/ldap-client.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/ldap/domain/ldap-client.service.ts b/src/core/ldap/domain/ldap-client.service.ts index c8dc47753..f72c067e7 100644 --- a/src/core/ldap/domain/ldap-client.service.ts +++ b/src/core/ldap/domain/ldap-client.service.ts @@ -523,7 +523,8 @@ export class LdapClientService { }`, ); return this.sleep(currentDelay).then( - () => attempt(remainingRetries - 1, currentDelay * LdapClientService.EXPONENTIAL_BACKOFF_FACTOR), // Exponential backoff + () => + attempt(remainingRetries - 1, currentDelay * LdapClientService.EXPONENTIAL_BACKOFF_FACTOR), // Exponential backoff ); }); }; From 7540225922dbdf620afdb271647fc95d18c30d92 Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Sun, 8 Dec 2024 09:33:43 +0100 Subject: [PATCH 03/11] timeout_minutes: 35 --- .../image-and-helm-publish-check-deploy-on-push-scheduled.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml b/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml index 7dbe11e1e..2af0e955b 100644 --- a/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml +++ b/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml @@ -41,7 +41,7 @@ jobs: with: node_version: '18' deploy_stage: 'dev' - timeout_minutes: 20 + timeout_minutes: 35 permissions: contents: read secrets: inherit From 654e81b34d3af9e9a563a0b770183ee0ac1eea40 Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Sun, 8 Dec 2024 10:32:54 +0100 Subject: [PATCH 04/11] Revert "timeout_minutes: 35" This reverts commit 7540225922dbdf620afdb271647fc95d18c30d92. --- .../image-and-helm-publish-check-deploy-on-push-scheduled.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml b/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml index 2af0e955b..7dbe11e1e 100644 --- a/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml +++ b/.github/workflows/image-and-helm-publish-check-deploy-on-push-scheduled.yml @@ -41,7 +41,7 @@ jobs: with: node_version: '18' deploy_stage: 'dev' - timeout_minutes: 35 + timeout_minutes: 20 permissions: contents: read secrets: inherit From 414e34a13734b5bca71ea9c1b3e61160103bdfab Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Sun, 8 Dec 2024 14:01:30 +0100 Subject: [PATCH 05/11] Fix LdapClientService Async Procedure --- .../ldap/domain/ldap-client.service.spec.ts | 8 +- src/core/ldap/domain/ldap-client.service.ts | 73 +++++++++++-------- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/src/core/ldap/domain/ldap-client.service.spec.ts b/src/core/ldap/domain/ldap-client.service.spec.ts index 19015a2a2..8cf804b11 100644 --- a/src/core/ldap/domain/ldap-client.service.spec.ts +++ b/src/core/ldap/domain/ldap-client.service.spec.ts @@ -127,7 +127,7 @@ describe('LDAP Client Service', () => { expect(em).toBeDefined(); }); - describe('getRootName', () => { + describe('getRootName', () => { //WORKS it('when emailDomain is neither schule-sh.de nor ersatzschule-sh.de should return LdapEmailDomainError', async () => { ldapClientMock.getClient.mockImplementation(() => { clientMock.bind.mockResolvedValue(); @@ -180,7 +180,7 @@ describe('LDAP Client Service', () => { }); }); - describe('isLehrerExisting', () => { + describe('isLehrerExisting', () => { //WORKS const fakeEmailDomain: string = 'schule-sh.de'; it('when lehrer exists should return true', async () => { ldapClientMock.getClient.mockImplementation(() => { @@ -234,7 +234,7 @@ describe('LDAP Client Service', () => { }); }); - describe('creation', () => { + describe('creation', () => { //WORKS const fakeEmailDomain: string = 'schule-sh.de'; describe('lehrer', () => { @@ -281,7 +281,7 @@ describe('LDAP Client Service', () => { expect(loggerMock.info).toHaveBeenLastCalledWith(`LDAP: Successfully created lehrer ${lehrerUid}`); }); - it('when adding fails should log error', async () => { + it('when adding fails should log error', async () => { //REASON ldapClientMock.getClient.mockImplementation(() => { clientMock.bind.mockResolvedValue(); clientMock.search.mockResolvedValueOnce(createMock({ searchEntries: [] })); //mock: lehrer not present diff --git a/src/core/ldap/domain/ldap-client.service.ts b/src/core/ldap/domain/ldap-client.service.ts index f72c067e7..a69841778 100644 --- a/src/core/ldap/domain/ldap-client.service.ts +++ b/src/core/ldap/domain/ldap-client.service.ts @@ -16,6 +16,7 @@ import { LdapCreateLehrerError } from '../error/ldap-create-lehrer.error.js'; import { LdapModifyEmailError } from '../error/ldap-modify-email.error.js'; import { PersonRepository } from '../../../modules/person/persistence/person.repository.js'; import { Person } from '../../../modules/person/domain/person.js'; +import { DomainError } from '../../../shared/error/domain.error.js'; export type PersonData = { vorname: string; @@ -27,6 +28,8 @@ export type PersonData = { @Injectable() export class LdapClientService { + // DEFAULT_RETRIES = 5 & EXPONENTIAL_BACKOFF_FACTOR = 3, will produce retry sequence: 1sek, 3sek, 9sek, 27sek, 81sek + public static readonly DEFAULT_RETRIES: number = 5; public static readonly EXPONENTIAL_BACKOFF_FACTOR: number = 3; @@ -493,43 +496,49 @@ export class LdapClientService { }); } - private executeWithRetry( + private async executeWithRetry( func: () => Promise>, retries: number, delay: number = 1000, ): Promise> { - const attempt = (remainingRetries: number, currentDelay: number): Promise> => { - const currentAttempt: number = retries - remainingRetries + 1; - return func() - .then((result: Result) => { - if (result.ok) { - return result; - } else { - throw new Error(`Function returned error: ${result.error.message}`); - } - }) - .catch((error: Error) => { - this.logger.error(`Attempt ${currentAttempt}: Failed with error: ${error.message}`); - if (remainingRetries === 0) { - this.logger.error(`All ${retries} attempts failed. Exiting with failure.`); - return { - ok: false, - error: new Error('Maximum retries reached without success.'), - }; - } - this.logger.warning( - `Attempt ${currentAttempt} failed. Retrying in ${currentDelay}ms... Remaining retries: ${ - remainingRetries - 1 - }`, - ); - return this.sleep(currentDelay).then( - () => - attempt(remainingRetries - 1, currentDelay * LdapClientService.EXPONENTIAL_BACKOFF_FACTOR), // Exponential backoff - ); - }); - }; + let currentAttempt: number = 0; + + while (currentAttempt < retries) { + currentAttempt++; + try { + // eslint-disable-next-line no-await-in-loop + const result: Result = await func(); + if (result.ok || result.error instanceof DomainError) { + return result; + } else { + throw new Error(`Function returned non Domain error: ${result.error.message}`); + } + } catch (error) { + this.logger.error(`Attempt ${currentAttempt}: Failed`); + if (currentAttempt >= retries) { + this.logger.error(`All ${retries} attempts failed. Exiting with failure.`); + return { + ok: false, + error: new Error('Maximum retries reached without success.'), + }; + } + + const currentDelay: number = + delay * Math.pow(LdapClientService.EXPONENTIAL_BACKOFF_FACTOR, currentAttempt - 1); + this.logger.warning( + `Attempt ${currentAttempt} failed. Retrying in ${currentDelay}ms... Remaining retries: ${retries - currentAttempt}`, + ); - return attempt(retries, delay); + // eslint-disable-next-line no-await-in-loop + await this.sleep(currentDelay); + } + } + + // Fallback to ensure the function always returns a value (should never reach here due to return in loop). + return { + ok: false, + error: new Error('Unexpected failure in executeWithRetry.'), + }; } private async sleep(ms: number): Promise { From d93cb25608e909ad275bbc16aa9a64ac4b4c482e Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Sun, 8 Dec 2024 14:03:00 +0100 Subject: [PATCH 06/11] Remove Comments --- src/core/ldap/domain/ldap-client.service.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/ldap/domain/ldap-client.service.spec.ts b/src/core/ldap/domain/ldap-client.service.spec.ts index 8cf804b11..19015a2a2 100644 --- a/src/core/ldap/domain/ldap-client.service.spec.ts +++ b/src/core/ldap/domain/ldap-client.service.spec.ts @@ -127,7 +127,7 @@ describe('LDAP Client Service', () => { expect(em).toBeDefined(); }); - describe('getRootName', () => { //WORKS + describe('getRootName', () => { it('when emailDomain is neither schule-sh.de nor ersatzschule-sh.de should return LdapEmailDomainError', async () => { ldapClientMock.getClient.mockImplementation(() => { clientMock.bind.mockResolvedValue(); @@ -180,7 +180,7 @@ describe('LDAP Client Service', () => { }); }); - describe('isLehrerExisting', () => { //WORKS + describe('isLehrerExisting', () => { const fakeEmailDomain: string = 'schule-sh.de'; it('when lehrer exists should return true', async () => { ldapClientMock.getClient.mockImplementation(() => { @@ -234,7 +234,7 @@ describe('LDAP Client Service', () => { }); }); - describe('creation', () => { //WORKS + describe('creation', () => { const fakeEmailDomain: string = 'schule-sh.de'; describe('lehrer', () => { @@ -281,7 +281,7 @@ describe('LDAP Client Service', () => { expect(loggerMock.info).toHaveBeenLastCalledWith(`LDAP: Successfully created lehrer ${lehrerUid}`); }); - it('when adding fails should log error', async () => { //REASON + it('when adding fails should log error', async () => { ldapClientMock.getClient.mockImplementation(() => { clientMock.bind.mockResolvedValue(); clientMock.search.mockResolvedValueOnce(createMock({ searchEntries: [] })); //mock: lehrer not present From 73cf2d3f4dcf813cab27c5b719126bad54085a98 Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Mon, 9 Dec 2024 11:52:34 +0100 Subject: [PATCH 07/11] Remove Unreachable Fallback --- src/core/ldap/domain/ldap-client.service.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/core/ldap/domain/ldap-client.service.ts b/src/core/ldap/domain/ldap-client.service.ts index a69841778..99ff625a2 100644 --- a/src/core/ldap/domain/ldap-client.service.ts +++ b/src/core/ldap/domain/ldap-client.service.ts @@ -30,7 +30,7 @@ export type PersonData = { export class LdapClientService { // DEFAULT_RETRIES = 5 & EXPONENTIAL_BACKOFF_FACTOR = 3, will produce retry sequence: 1sek, 3sek, 9sek, 27sek, 81sek - public static readonly DEFAULT_RETRIES: number = 5; + public static readonly DEFAULT_RETRIES: number = 3; public static readonly EXPONENTIAL_BACKOFF_FACTOR: number = 3; @@ -501,10 +501,9 @@ export class LdapClientService { retries: number, delay: number = 1000, ): Promise> { - let currentAttempt: number = 0; + let currentAttempt: number = 1; - while (currentAttempt < retries) { - currentAttempt++; + while (currentAttempt <= retries) { try { // eslint-disable-next-line no-await-in-loop const result: Result = await func(); @@ -515,14 +514,6 @@ export class LdapClientService { } } catch (error) { this.logger.error(`Attempt ${currentAttempt}: Failed`); - if (currentAttempt >= retries) { - this.logger.error(`All ${retries} attempts failed. Exiting with failure.`); - return { - ok: false, - error: new Error('Maximum retries reached without success.'), - }; - } - const currentDelay: number = delay * Math.pow(LdapClientService.EXPONENTIAL_BACKOFF_FACTOR, currentAttempt - 1); this.logger.warning( @@ -532,12 +523,12 @@ export class LdapClientService { // eslint-disable-next-line no-await-in-loop await this.sleep(currentDelay); } + currentAttempt++; } - - // Fallback to ensure the function always returns a value (should never reach here due to return in loop). + this.logger.error(`All ${retries} attempts failed. Exiting with failure.`); return { ok: false, - error: new Error('Unexpected failure in executeWithRetry.'), + error: new Error('Maximum retries reached without success.'), }; } From ff465dd38d16fe76b3d94e8088c17af5f28df231 Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Mon, 9 Dec 2024 14:07:36 +0100 Subject: [PATCH 08/11] Optimize Retry Exponential Parameter --- src/core/ldap/domain/ldap-client.service.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/core/ldap/domain/ldap-client.service.ts b/src/core/ldap/domain/ldap-client.service.ts index 99ff625a2..6322c8fe2 100644 --- a/src/core/ldap/domain/ldap-client.service.ts +++ b/src/core/ldap/domain/ldap-client.service.ts @@ -28,11 +28,7 @@ export type PersonData = { @Injectable() export class LdapClientService { - // DEFAULT_RETRIES = 5 & EXPONENTIAL_BACKOFF_FACTOR = 3, will produce retry sequence: 1sek, 3sek, 9sek, 27sek, 81sek - - public static readonly DEFAULT_RETRIES: number = 3; - - public static readonly EXPONENTIAL_BACKOFF_FACTOR: number = 3; + public static readonly DEFAULT_RETRIES: number = 4; // e.g. DEFAULT_RETRIES = 4 will produce retry sequence: 1sek, 8sek, 27sek, 64sek (1000ms * retrycounter^3) public static readonly OEFFENTLICHE_SCHULEN_DOMAIN_DEFAULT: string = 'schule-sh.de'; @@ -513,9 +509,7 @@ export class LdapClientService { throw new Error(`Function returned non Domain error: ${result.error.message}`); } } catch (error) { - this.logger.error(`Attempt ${currentAttempt}: Failed`); - const currentDelay: number = - delay * Math.pow(LdapClientService.EXPONENTIAL_BACKOFF_FACTOR, currentAttempt - 1); + const currentDelay: number = delay * Math.pow(currentAttempt, 3); this.logger.warning( `Attempt ${currentAttempt} failed. Retrying in ${currentDelay}ms... Remaining retries: ${retries - currentAttempt}`, ); From 3771da3e58591df66b70503b2b1ceeeaff7022ab Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Tue, 10 Dec 2024 10:13:19 +0100 Subject: [PATCH 09/11] Add & Fix Tests --- .../ldap/domain/ldap-client.service.spec.ts | 41 +++++++++++++++---- src/core/ldap/domain/ldap-client.service.ts | 18 ++++---- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/core/ldap/domain/ldap-client.service.spec.ts b/src/core/ldap/domain/ldap-client.service.spec.ts index 19015a2a2..85baca2c8 100644 --- a/src/core/ldap/domain/ldap-client.service.spec.ts +++ b/src/core/ldap/domain/ldap-client.service.spec.ts @@ -234,6 +234,31 @@ describe('LDAP Client Service', () => { }); }); + describe('executeWithRetry', () => { + it('when writing operation fails it should automatically retry the operation', async () => { + ldapClientMock.getClient.mockImplementation(() => { + clientMock.bind.mockResolvedValue(); + clientMock.add.mockRejectedValue(new Error()); + clientMock.search.mockResolvedValueOnce(createMock()); //mock existsLehrer + + return clientMock; + }); + const testLehrer: PersonData = { + id: faker.string.uuid(), + vorname: faker.person.firstName(), + familienname: faker.person.lastName(), + referrer: faker.lorem.word(), + ldapEntryUUID: faker.string.uuid(), + }; + const result: Result = await ldapClientService.createLehrer(testLehrer, 'schule-sh.de'); + + expect(result.ok).toBeFalsy(); + expect(clientMock.bind).toHaveBeenCalledTimes(2); + expect(loggerMock.warning).toHaveBeenCalledWith(expect.stringContaining('Attempt 1 failed')); + expect(loggerMock.warning).toHaveBeenCalledWith(expect.stringContaining('Attempt 2 failed')); + }); + }); + describe('creation', () => { const fakeEmailDomain: string = 'schule-sh.de'; @@ -299,7 +324,7 @@ describe('LDAP Client Service', () => { const result: Result = await ldapClientService.createLehrer(testLehrer, fakeEmailDomain); if (result.ok) throw Error(); - expect(loggerMock.error).toHaveBeenLastCalledWith( + expect(loggerMock.error).toHaveBeenCalledWith( `LDAP: Creating lehrer FAILED, uid:${lehrerUid}, errMsg:{}`, ); expect(result.error).toEqual(new LdapCreateLehrerError()); @@ -641,7 +666,7 @@ describe('LDAP Client Service', () => { describe('when called with invalid emailDomain', () => { it('should return LdapEmailDomainError', async () => { - personRepoMock.findById.mockResolvedValueOnce(person); + personRepoMock.findById.mockResolvedValue(person); const result: Result = await ldapClientService.changeEmailAddressByPersonId( faker.string.uuid(), @@ -656,7 +681,7 @@ describe('LDAP Client Service', () => { describe('when called with newEmailAddress that is not splittable', () => { it('should return LdapEmailAddressError', async () => { - personRepoMock.findById.mockResolvedValueOnce(person); + personRepoMock.findById.mockResolvedValue(person); const result: Result = await ldapClientService.changeEmailAddressByPersonId( faker.string.uuid(), @@ -702,11 +727,11 @@ describe('LDAP Client Service', () => { const currentEmailAddress: string = 'current-address@schule-sh.de'; it('should set mailAlternativeAddress as current mailPrimaryAddress and throw LdapPersonEntryChangedEvent', async () => { - personRepoMock.findById.mockResolvedValueOnce(person); + personRepoMock.findById.mockResolvedValue(person); ldapClientMock.getClient.mockImplementation(() => { - clientMock.bind.mockResolvedValueOnce(); - clientMock.search.mockResolvedValueOnce( + clientMock.bind.mockResolvedValue(); + clientMock.search.mockResolvedValue( createMock({ searchEntries: [ createMock({ @@ -716,7 +741,7 @@ describe('LDAP Client Service', () => { ], }), ); - clientMock.modify.mockRejectedValueOnce(new Error()); + clientMock.modify.mockRejectedValue(new Error()); return clientMock; }); @@ -728,7 +753,7 @@ describe('LDAP Client Service', () => { if (result.ok) throw Error(); expect(result.error).toStrictEqual(new LdapModifyEmailError()); - expect(loggerMock.error).toHaveBeenLastCalledWith( + expect(loggerMock.error).toHaveBeenCalledWith( `LDAP: Modifying mailPrimaryAddress and mailAlternativeAddress FAILED, errMsg:{}`, ); expect(eventServiceMock.publish).toHaveBeenCalledTimes(0); diff --git a/src/core/ldap/domain/ldap-client.service.ts b/src/core/ldap/domain/ldap-client.service.ts index 6322c8fe2..8e8ea4c10 100644 --- a/src/core/ldap/domain/ldap-client.service.ts +++ b/src/core/ldap/domain/ldap-client.service.ts @@ -16,7 +16,6 @@ import { LdapCreateLehrerError } from '../error/ldap-create-lehrer.error.js'; import { LdapModifyEmailError } from '../error/ldap-modify-email.error.js'; import { PersonRepository } from '../../../modules/person/persistence/person.repository.js'; import { Person } from '../../../modules/person/domain/person.js'; -import { DomainError } from '../../../shared/error/domain.error.js'; export type PersonData = { vorname: string; @@ -28,7 +27,7 @@ export type PersonData = { @Injectable() export class LdapClientService { - public static readonly DEFAULT_RETRIES: number = 4; // e.g. DEFAULT_RETRIES = 4 will produce retry sequence: 1sek, 8sek, 27sek, 64sek (1000ms * retrycounter^3) + public static readonly DEFAULT_RETRIES: number = 2; // e.g. DEFAULT_RETRIES = 4 will produce retry sequence: 1sek, 8sek, 27sek, 64sek (1000ms * retrycounter^3) public static readonly OEFFENTLICHE_SCHULEN_DOMAIN_DEFAULT: string = 'schule-sh.de'; @@ -498,15 +497,19 @@ export class LdapClientService { delay: number = 1000, ): Promise> { let currentAttempt: number = 1; + let result: Result = { + ok: false, + error: new Error('executeWithRetry default fallback'), + }; while (currentAttempt <= retries) { try { // eslint-disable-next-line no-await-in-loop - const result: Result = await func(); - if (result.ok || result.error instanceof DomainError) { + result = await func(); + if (result.ok) { return result; } else { - throw new Error(`Function returned non Domain error: ${result.error.message}`); + throw new Error(`Function returned error: ${result.error.message}`); } } catch (error) { const currentDelay: number = delay * Math.pow(currentAttempt, 3); @@ -520,10 +523,7 @@ export class LdapClientService { currentAttempt++; } this.logger.error(`All ${retries} attempts failed. Exiting with failure.`); - return { - ok: false, - error: new Error('Maximum retries reached without success.'), - }; + return result; } private async sleep(ms: number): Promise { From e33c66da0b2a56f9a0d75e6d7beba40700c61af6 Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Tue, 10 Dec 2024 12:13:54 +0100 Subject: [PATCH 10/11] 3 Attempts --- src/core/ldap/domain/ldap-client.service.spec.ts | 3 ++- src/core/ldap/domain/ldap-client.service.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/ldap/domain/ldap-client.service.spec.ts b/src/core/ldap/domain/ldap-client.service.spec.ts index 85baca2c8..7f13489ec 100644 --- a/src/core/ldap/domain/ldap-client.service.spec.ts +++ b/src/core/ldap/domain/ldap-client.service.spec.ts @@ -253,9 +253,10 @@ describe('LDAP Client Service', () => { const result: Result = await ldapClientService.createLehrer(testLehrer, 'schule-sh.de'); expect(result.ok).toBeFalsy(); - expect(clientMock.bind).toHaveBeenCalledTimes(2); + expect(clientMock.bind).toHaveBeenCalledTimes(3); expect(loggerMock.warning).toHaveBeenCalledWith(expect.stringContaining('Attempt 1 failed')); expect(loggerMock.warning).toHaveBeenCalledWith(expect.stringContaining('Attempt 2 failed')); + expect(loggerMock.warning).toHaveBeenCalledWith(expect.stringContaining('Attempt 3 failed')); }); }); diff --git a/src/core/ldap/domain/ldap-client.service.ts b/src/core/ldap/domain/ldap-client.service.ts index 8e8ea4c10..4aeb5b013 100644 --- a/src/core/ldap/domain/ldap-client.service.ts +++ b/src/core/ldap/domain/ldap-client.service.ts @@ -27,7 +27,7 @@ export type PersonData = { @Injectable() export class LdapClientService { - public static readonly DEFAULT_RETRIES: number = 2; // e.g. DEFAULT_RETRIES = 4 will produce retry sequence: 1sek, 8sek, 27sek, 64sek (1000ms * retrycounter^3) + public static readonly DEFAULT_RETRIES: number = 3; // e.g. DEFAULT_RETRIES = 3 will produce retry sequence: 1sek, 8sek, 27sek (1000ms * retrycounter^3) public static readonly OEFFENTLICHE_SCHULEN_DOMAIN_DEFAULT: string = 'schule-sh.de'; From 4c1d638fe58f29ada1c86b8a395fef4e39069c9c Mon Sep 17 00:00:00 2001 From: Caspar Neumann Date: Fri, 13 Dec 2024 11:47:20 +0100 Subject: [PATCH 11/11] Wrap changeUserPasswordByPersonId --- src/core/ldap/domain/ldap-client.service.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/core/ldap/domain/ldap-client.service.ts b/src/core/ldap/domain/ldap-client.service.ts index 15c754117..bc2984ead 100644 --- a/src/core/ldap/domain/ldap-client.service.ts +++ b/src/core/ldap/domain/ldap-client.service.ts @@ -110,6 +110,13 @@ export class LdapClientService { ); } + public async changeUserPasswordByPersonId(personId: PersonID, referrer: PersonReferrer): Promise> { + return this.executeWithRetry( + () => this.changeUserPasswordByPersonIdInternal(personId, referrer), + LdapClientService.DEFAULT_RETRIES, + ); + } + public async isLehrerExisting(referrer: string, domain: string): Promise> { return this.executeWithRetry( () => this.isLehrerExistingInternal(referrer, domain), @@ -483,7 +490,10 @@ export class LdapClientService { }); } - public async changeUserPasswordByPersonId(personId: PersonID, referrer: PersonReferrer): Promise> { + private async changeUserPasswordByPersonIdInternal( + personId: PersonID, + referrer: PersonReferrer, + ): Promise> { // Converted to avoid PersonRepository-ref, UEM-password-generation //const referrer: string | undefined = await this.getPersonReferrerOrUndefined(personId); const userPassword: string = generatePassword();