diff --git a/src/app/contacts-app/contacts-app.component.spec.ts b/src/app/contacts-app/contacts-app.component.spec.ts new file mode 100644 index 000000000..109a3d915 --- /dev/null +++ b/src/app/contacts-app/contacts-app.component.spec.ts @@ -0,0 +1,59 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { runContactImportQueue } from './contacts-app.component'; + +describe('runContactImportQueue', () => { + it('limits concurrent contact saves', async () => { + const contacts = Array.from({ length: 10 }, (_, i) => i); + const started: number[] = []; + let activeSaves = 0; + let maxActiveSaves = 0; + + await runContactImportQueue(contacts, 3, async contact => { + started.push(contact); + activeSaves += 1; + maxActiveSaves = Math.max(maxActiveSaves, activeSaves); + + await new Promise(resolve => setTimeout(resolve, 0)); + + activeSaves -= 1; + }); + + expect(started).toEqual(contacts); + expect(maxActiveSaves).toBe(3); + }); + + it('falls back to a single worker for invalid concurrency limits', async () => { + const contacts = [1, 2, 3]; + let activeSaves = 0; + let maxActiveSaves = 0; + + await runContactImportQueue(contacts, 0, async () => { + activeSaves += 1; + maxActiveSaves = Math.max(maxActiveSaves, activeSaves); + + await new Promise(resolve => setTimeout(resolve, 0)); + + activeSaves -= 1; + }); + + expect(maxActiveSaves).toBe(1); + }); +}); diff --git a/src/app/contacts-app/contacts-app.component.ts b/src/app/contacts-app/contacts-app.component.ts index 7e7ba2d10..7c3c355ed 100644 --- a/src/app/contacts-app/contacts-app.component.ts +++ b/src/app/contacts-app/contacts-app.component.ts @@ -35,6 +35,27 @@ import { VcfImportDialogComponent, VcfImportDialogResult } from './vcf-import-di import { v4 as uuidv4 } from 'uuid'; import { take } from 'rxjs/operators'; +const CONTACT_IMPORT_SAVE_CONCURRENCY = 5; + +export async function runContactImportQueue( + contacts: T[], + concurrency: number, + saveContact: (contact: T) => Promise +): Promise { + const pendingContacts = contacts.slice(); + const workerCount = Math.min( + Math.max(1, concurrency), + pendingContacts.length || 1 + ); + + await Promise.all(Array.from({ length: workerCount }, async () => { + while (pendingContacts.length > 0) { + const contact = pendingContacts.shift(); + await saveContact(contact); + } + })); +} + @Component({ // eslint-disable-next-line @angular-eslint/component-selector selector: 'contacts-app-root', @@ -278,7 +299,7 @@ export class ContactsAppComponent { if (!result) { return; } - const promises = []; + const contactsToImport: Contact[] = []; for (const c of contacts) { // Assign an uuid unless it already has one. // Without it we won't be able to add them to groups if requested @@ -296,10 +317,14 @@ export class ContactsAppComponent { if (result.newCategory) { c.categories = c.categories.concat(result.newCategory); } - promises.push(this.contactsservice.saveContact(c, false)); + contactsToImport.push(c); } } - Promise.all(promises).finally(() => this.contactsservice.reload()); + runContactImportQueue( + contactsToImport, + CONTACT_IMPORT_SAVE_CONCURRENCY, + contact => this.contactsservice.saveContact(contact, false) + ).finally(() => this.contactsservice.reload()); if (result.addToGroup) { this.addContactsToGroup(result.addToGroup, contacts); }