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

  1. Retry the link operation — it is idempotent (the existing-partnerUserId guard short-circuits if already linked; null-filtered collections skip already-stamped rows).
  2. If persistent, log which update* calls rejected (group results by collection) to isolate the object/record.
  3. Check app-server logs for 5xx or RLS denials across person/application/partnerLink/partnerService/partnerContent/company updates.
  4. 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

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


AI-assisted analysis of twentyhq/twenty@1f5dd2bbd2 (2026-08-12). Data as JSON: /api/errors/e9e45089831058dc. Report an issue: GitHub.