Skip to content

Commit 1ee0dc9

Browse files
committed
fix: prevent concurrent mailing list connects from racing ownership (CM-1318)
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent 12d147c commit 1ee0dc9

2 files changed

Lines changed: 33 additions & 0 deletions

File tree

backend/src/services/integrationService.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { CommonIntegrationService, getGithubInstallationToken } from '@crowd/com
1818
import { ICreateInsightsProject } from '@crowd/data-access-layer/src/collections'
1919
import {
2020
findMailingListsOwnedByOtherIntegration,
21+
lockMailingListSourceUrls,
2122
upsertMailingLists,
2223
} from '@crowd/data-access-layer/src/mailinglist'
2324
import {
@@ -1467,6 +1468,14 @@ export default class IntegrationService {
14671468
options,
14681469
)
14691470

1471+
// Serialize concurrent connects touching the same sourceUrl(s) so the
1472+
// ownership check below and the upsert that follows it can't be
1473+
// straddled by another transaction re-pointing ownership in between.
1474+
await lockMailingListSourceUrls(
1475+
qx,
1476+
lists.map((l) => l.sourceUrl),
1477+
)
1478+
14701479
const conflicts = await findMailingListsOwnedByOtherIntegration(qx, integration.id, lists)
14711480
if (conflicts.length > 0) {
14721481
throw new Error400(

services/libs/data-access-layer/src/mailinglist/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,30 @@ export interface IMailingListToOnboard {
55
sourceUrl: string
66
}
77

8+
/**
9+
* Take a transaction-scoped advisory lock per sourceUrl, so two concurrent
10+
* connect/update calls touching the same sourceUrl serialize instead of both
11+
* passing `findMailingListsOwnedByOtherIntegration`'s check and then racing
12+
* in `upsertMailingLists`'s `ON CONFLICT DO UPDATE` (which silently
13+
* reassigns ownership to whichever transaction commits last). Locks release
14+
* automatically at commit/rollback. Call before the ownership check, on a
15+
* `qx` bound to the same transaction as the rest of the connect flow.
16+
* @param qx - Query executor (must be transactional)
17+
* @param sourceUrls - sourceUrls about to be checked/upserted
18+
*/
19+
export async function lockMailingListSourceUrls(
20+
qx: QueryExecutor,
21+
sourceUrls: string[],
22+
): Promise<void> {
23+
// Sorted so overlapping concurrent requests always acquire locks in the
24+
// same order, avoiding a deadlock between two multi-list connects.
25+
for (const sourceUrl of [...sourceUrls].sort()) {
26+
await qx.selectNone(`SELECT pg_advisory_xact_lock(hashtext($(sourceUrl))::bigint)`, {
27+
sourceUrl,
28+
})
29+
}
30+
}
31+
832
/**
933
* Find sourceUrls already onboarded under a different, non-deleted
1034
* integration. Used to reject a connect/update call before it silently

0 commit comments

Comments
 (0)