{"record":{"id":"8aca3d0622f534b2","repo":"mastra-ai/mastra","slug":"cannot-create-a-knowledge-merge-cycle","errorCode":null,"errorMessage":"Cannot create a knowledge merge cycle","messagePattern":"Cannot create a knowledge merge cycle","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/core/src/storage/domains/knowledge/inmemory.ts","lineNumber":256,"sourceCode":"      this.#enqueue('node', input.id, 'delete', createKnowledgeUlid(), existing.scope);\n      for (const record of this.#db.knowledgeRecords.values()) {\n        if (record.node !== input.id) continue;\n        this.#enqueue('record', record.id, 'delete', createKnowledgeUlid(), record.scope);\n        if (!record.deletedAt) this.#enqueue('record', record.id, 'upsert', createKnowledgeUlid(), record.scope);\n      }\n    }\n    this.#enqueue('node', input.id, 'upsert', updated.version, scope);\n    return cloneNode(updated);\n  }\n\n  async mergeNodes(input: { sourceId: string; targetId: string; sourceVersion: number }): Promise<KnowledgeNode> {\n    if (input.sourceId === input.targetId) throw new Error('Cannot merge a knowledge node into itself');\n    const source = this.#db.knowledgeNodes.get(input.sourceId);\n    if (!source) throw new KnowledgeNotFoundError('node', input.sourceId);\n    if (source.version !== input.sourceVersion) throw new KnowledgeConflictError(input.sourceId);\n    const target = this.#resolveTerminalNode(input.targetId);\n    if (!target) throw new KnowledgeNotFoundError('node', input.targetId);\n    if (target.id === source.id) throw new Error('Cannot create a knowledge merge cycle');\n    if (!isKnowledgeScopeVisible(target.scope, source.scope)) {\n      throw new Error('Cannot merge a knowledge node into a target that is narrower than its source scope');\n    }\n\n    for (const [id, record] of this.#db.knowledgeRecords) {\n      if (record.node === source.id) {\n        this.#db.knowledgeRecords.set(id, { ...record, node: target.id });\n        this.#enqueue('record', id, record.deletedAt ? 'delete' : 'upsert', createKnowledgeUlid(), record.scope);\n      }\n    }\n    for (const [key, mentions] of this.#db.knowledgeMentions) {\n      if (mentions.has(source.id)) {\n        const next = new Set(mentions);\n        next.delete(source.id);\n        next.add(target.id);\n        this.#db.knowledgeMentions.set(key, next);\n        const separator = key.indexOf(':');\n        const sourceType = key.slice(0, separator);","sourceCodeStart":238,"sourceCodeEnd":274,"githubUrl":"https://github.com/mastra-ai/mastra/blob/75dd419e613fe9c39f846ffc500716141b74fda6/packages/core/src/storage/domains/knowledge/inmemory.ts#L238-L274","documentation":"InMemoryKnowledgeStorage.mergeNodes() throws this when merging would create a cycle: after resolving the target's merge chain to its terminal node, the terminal target resolves to the same node as the source. Allowing it would make the merged-into graph self-referential and break #resolveTerminalNode lookups. The library detects this eagerly before mutating any records.","triggerScenarios":"Calling mergeNodes() where input.targetId is the source node itself, or where input.targetId is a node whose mergedInto chain (directly or transitively) points back to input.sourceId.","commonSituations":"Retrying a merge after a partial failure with stale IDs; UIs letting a user pick the same node as both source and target; automated dedupe jobs that merge clusters without checking chain direction; concurrent merges where node A was already merged into B and a job now tries B into A.","solutions":["Check input.sourceId !== input.targetId before calling mergeNodes().","Resolve the target's terminal node first (follow mergedInto links) and skip the merge if it equals the source.","If the intent is to merge the target into the source, swap the arguments so the merge points the newer node at the surviving one.","Load fresh node state (versions/mergedInto) before merging to avoid stale-id retries."],"exampleFix":"// before\nawait storage.mergeNodes({ sourceId: b.id, sourceVersion: b.version, targetId: a.id }); // a already merged into b\n// after\nconst terminal = resolveTerminal(a); // follow mergedInto chain\nif (terminal.id !== b.id) {\n  await storage.mergeNodes({ sourceId: b.id, sourceVersion: b.version, targetId: terminal.id });\n}","handlingStrategy":"validation","validationCode":"if (input.sourceId === input.targetId) throw new SkipMerge();\n// additionally resolve the target's chain if you track mergedInto yourself\nconst target = nodes.get(input.targetId);\nwhile (target?.mergedInto) target = nodes.get(target.mergedInto);\nif (target?.id === input.sourceId) throw new SkipMerge('would create cycle');","typeGuard":"function isSafeMergePair(source: KnowledgeNode, target: KnowledgeNode): boolean {\n  let t: KnowledgeNode | undefined = target;\n  const seen = new Set<string>();\n  while (t?.mergedInto) {\n    if (t.id === source.id || seen.has(t.id)) return false;\n    seen.add(t.id);\n    t = t.mergedInto;\n  }\n  return t?.id !== source.id;\n}","tryCatchPattern":"try {\n  await storage.mergeNodes(input);\n} catch (e) {\n  if (e.message.includes('merge cycle')) return; // skip: already merged or self-merge\n  throw e;\n}","preventionTips":["Never pass the same ID as sourceId and targetId.","Resolve terminal nodes before scheduling merges in bulk dedupe jobs.","Run merges sequentially against an in-memory store.","Keep node version/mergedInto state fresh; refetch before each merge."],"tags":["knowledge","merge","cycle","inmemory-storage"],"backgroundTag":"merge-cycle-detected","analyzedSha":"75dd419e613fe9c39f846ffc500716141b74fda6","analyzedAt":"2026-08-30T00:15:31.844Z","schemaVersion":2},"datasetVersion":"2026-08-30T03:17:51.788Z"}