twentyhq/twenty · error · Error
link-partner-user: ${failed} cascade write(s) failed for ${p
Error message
link-partner-user: ${failed} cascade write(s) failed for ${partnerId} — retrying What it means
linkPartnerUser fans out partnerUser-stamps across Person, Application, PartnerLink, PartnerService, PartnerContent, and Company via Promise.allSettled and throws if any rejected. The throw is by design: the Partner's own partnerUserId is stamped LAST specifically so that a partial cascade leaves the partner unstamped and a re-invocation redoes the whole cascade idempotently. The comment block documents this ordering and the known non-atomic race.
Source
Thrown at packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts:70
if (companyId) {
const companyOwner = (await getCompanyPartnerUser(client, companyId)).company?.partnerUserId;
if (!companyOwner || companyOwner === memberId) {
companyWrites.push(updateCompanyPartnerUser(client, companyId, memberId));
}
}
const results = await Promise.allSettled([
...personIds.map((id) => updatePersonPartnerUser(client, id, memberId)),
...applicationIds.map((id) => updateApplicationPartnerUser(client, id, memberId)),
...partnerLinkIds.map((id) => updatePartnerLinkPartnerUser(client, id, memberId)),
...partnerServiceIds.map((id) => updatePartnerServicePartnerUser(client, id, memberId)),
...partnerContentIds.map((id) => updatePartnerContentPartnerUser(client, id, memberId)),
...companyWrites,
]);
const failed = results.filter((r) => r.status === 'rejected').length;
if (failed > 0) {
throw new Error(`link-partner-user: ${failed} cascade write(s) failed for ${partnerId} — retrying`);
}
// Re-check the claim immediately before the final stamp to narrow the concurrent-onboarding
// race (two members whose emails resolve to the same partner, created at once). This is NOT
// atomic — the API has no conditional update — so a simultaneous claim can still interleave;
// it only shrinks the window. Known limitation, consistent with the rest of the app.
const claimant = (await getPartnerOwner(client, partnerId)).partner?.partnerUserId;
if (claimant && claimant !== memberId) {
return { linked: false, reason: 'partner_already_linked_other' };
}
// Stamp the partner LAST — its own partnerUserId is the already-linked guard, so if any
// cascade write throws, the partner stays unstamped and a re-invocation redoes the cascade.
await updatePartnerPartnerUser(client, partnerId, memberId, new Date().toISOString());
return { linked: true, partnerId };
}
View on GitHub (pinned to 1f5dd2bbd2)
Solutions
- Retry the link operation — it is idempotent (the existing-partnerUserId guard short-circuits if already linked; null-filtered collections skip already-stamped rows).
- If persistent, log which update* calls rejected (group results by collection) to isolate the object/record.
- Check app-server logs for 5xx or RLS denials across person/application/partnerLink/partnerService/partnerContent/company updates.
- For the concurrent-onboarding race, ensure the workflow engine dedupes link calls per partnerId or serialize partner onboarding.
Example fix
// before
const failed = results.filter((r) => r.status === 'rejected').length;
if (failed > 0) {
throw new Error(`link-partner-user: ${failed} cascade write(s) failed for ${partnerId} — retrying`);
}
// after — keep the throw (drives idempotent retry), log which collections failed
const failedByKind = countFailuresByKind(results, { personIds, applicationIds, partnerLinkIds, partnerServiceIds, partnerContentIds, companyWrites });
const failed = Object.values(failedByKind).reduce((a, b) => a + b, 0);
if (failed > 0) {
console.error(`link-partner-user failures for ${partnerId}:`, failedByKind);
throw new Error(`link-partner-user: ${failed} cascade write(s) failed for ${partnerId} — retrying`);
} Defensive patterns
Strategy: retry
Validate before calling
// No client-side pre-validation prevents transient mid-cascade rejections. // Ensure the workflow engine retries thrown logic-function errors. // The partner stamp is intentionally LAST so partial failure stays retryable.
Try / catch
// linkPartnerUser is idempotent: the existing-partnerUserId guard short-circuits
// if already linked, and null-filtered collections skip already-stamped rows.
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await linkPartnerUser(client, { partnerId, memberId });
} catch (err) {
if (attempt === 2) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
}
} Prevention
- Rely on the documented ordering — the partner stamp is last, so a retry redoes the cascade safely.
- Ensure the workflow engine retries thrown errors with backoff.
- Serialize concurrent onboarding for emails that resolve to the same partner to narrow the documented race.
- Monitor app-server logs for RLS denials/5xx across the cascade objects.
When it happens
Trigger: One or more update*PartnerUser promises reject during the link cascade. Causes: transient network/5xx; a record deleted mid-cascade; RLS field-lock on one of the objects; concurrent onboarding of two members resolving to the same partner (the documented race).
Common situations: Two partner applications with emails mapping to the same partner onboarding at once; a partner link/service/content removed while linking; app-server restart mid-batch; field-lock misconfiguration on one cascade object.
Related errors
- on-opportunity-partner-assigned: ${failed} person clear(s) f
- on-opportunity-partner-assigned: ${failed} person stamp(s) f
- Missing env var: ${name}
- GraphQL errors: ${json.errors.map((e) => e.message).join(';
- Field "${fieldName}" not found on object "${objectName}" aft
AI-assisted analysis of twentyhq/twenty@1f5dd2bbd2 (2026-08-12).
Data as JSON: /api/errors/e9e45089831058dc.
Report an issue: GitHub.