{"record":{"id":"e9e45089831058dc","repo":"twentyhq/twenty","slug":"link-partner-user-failed-cascade-write-s-fail","errorCode":null,"errorMessage":"link-partner-user: ${failed} cascade write(s) failed for ${partnerId} — retrying","messagePattern":"link-partner-user: (.+?) cascade write\\(s\\) failed for (.+?) — retrying","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts","lineNumber":70,"sourceCode":"  if (companyId) {\n    const companyOwner = (await getCompanyPartnerUser(client, companyId)).company?.partnerUserId;\n    if (!companyOwner || companyOwner === memberId) {\n      companyWrites.push(updateCompanyPartnerUser(client, companyId, memberId));\n    }\n  }\n\n  const results = await Promise.allSettled([\n    ...personIds.map((id) => updatePersonPartnerUser(client, id, memberId)),\n    ...applicationIds.map((id) => updateApplicationPartnerUser(client, id, memberId)),\n    ...partnerLinkIds.map((id) => updatePartnerLinkPartnerUser(client, id, memberId)),\n    ...partnerServiceIds.map((id) => updatePartnerServicePartnerUser(client, id, memberId)),\n    ...partnerContentIds.map((id) => updatePartnerContentPartnerUser(client, id, memberId)),\n    ...companyWrites,\n  ]);\n\n  const failed = results.filter((r) => r.status === 'rejected').length;\n  if (failed > 0) {\n    throw new Error(`link-partner-user: ${failed} cascade write(s) failed for ${partnerId} — retrying`);\n  }\n\n  // Re-check the claim immediately before the final stamp to narrow the concurrent-onboarding\n  // race (two members whose emails resolve to the same partner, created at once). This is NOT\n  // atomic — the API has no conditional update — so a simultaneous claim can still interleave;\n  // it only shrinks the window. Known limitation, consistent with the rest of the app.\n  const claimant = (await getPartnerOwner(client, partnerId)).partner?.partnerUserId;\n  if (claimant && claimant !== memberId) {\n    return { linked: false, reason: 'partner_already_linked_other' };\n  }\n\n  // Stamp the partner LAST — its own partnerUserId is the already-linked guard, so if any\n  // cascade write throws, the partner stays unstamped and a re-invocation redoes the cascade.\n  await updatePartnerPartnerUser(client, partnerId, memberId, new Date().toISOString());\n  return { linked: true, partnerId };\n}\n","sourceCodeStart":52,"sourceCodeEnd":87,"githubUrl":"https://github.com/twentyhq/twenty/blob/1f5dd2bbd2a8da3419c8cfd52dd545c0024df1a6/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts#L52-L87","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","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."],"exampleFix":"// before\nconst failed = results.filter((r) => r.status === 'rejected').length;\nif (failed > 0) {\n  throw new Error(`link-partner-user: ${failed} cascade write(s) failed for ${partnerId} — retrying`);\n}\n\n// after — keep the throw (drives idempotent retry), log which collections failed\nconst failedByKind = countFailuresByKind(results, { personIds, applicationIds, partnerLinkIds, partnerServiceIds, partnerContentIds, companyWrites });\nconst failed = Object.values(failedByKind).reduce((a, b) => a + b, 0);\nif (failed > 0) {\n  console.error(`link-partner-user failures for ${partnerId}:`, failedByKind);\n  throw new Error(`link-partner-user: ${failed} cascade write(s) failed for ${partnerId} — retrying`);\n}","handlingStrategy":"retry","validationCode":"// No client-side pre-validation prevents transient mid-cascade rejections.\n// Ensure the workflow engine retries thrown logic-function errors.\n// The partner stamp is intentionally LAST so partial failure stays retryable.","typeGuard":null,"tryCatchPattern":"// linkPartnerUser is idempotent: the existing-partnerUserId guard short-circuits\n// if already linked, and null-filtered collections skip already-stamped rows.\nfor (let attempt = 0; attempt < 3; attempt++) {\n  try {\n    return await linkPartnerUser(client, { partnerId, memberId });\n  } catch (err) {\n    if (attempt === 2) throw err;\n    await new Promise((r) => setTimeout(r, 2 ** attempt * 500));\n  }\n}","preventionTips":["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."],"tags":["cascade","retry","rls","concurrency","partners"],"backgroundTag":null,"analyzedSha":"1f5dd2bbd2a8da3419c8cfd52dd545c0024df1a6","analyzedAt":"2026-08-12T15:37:27.593Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}