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);
}