mastra-ai/mastra · error

Cannot create a knowledge merge cycle

Error message

Cannot create a knowledge merge cycle

What it means

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.

Source

Thrown at packages/core/src/storage/domains/knowledge/inmemory.ts:256

      this.#enqueue('node', input.id, 'delete', createKnowledgeUlid(), existing.scope);
      for (const record of this.#db.knowledgeRecords.values()) {
        if (record.node !== input.id) continue;
        this.#enqueue('record', record.id, 'delete', createKnowledgeUlid(), record.scope);
        if (!record.deletedAt) this.#enqueue('record', record.id, 'upsert', createKnowledgeUlid(), record.scope);
      }
    }
    this.#enqueue('node', input.id, 'upsert', updated.version, scope);
    return cloneNode(updated);
  }

  async mergeNodes(input: { sourceId: string; targetId: string; sourceVersion: number }): Promise<KnowledgeNode> {
    if (input.sourceId === input.targetId) throw new Error('Cannot merge a knowledge node into itself');
    const source = this.#db.knowledgeNodes.get(input.sourceId);
    if (!source) throw new KnowledgeNotFoundError('node', input.sourceId);
    if (source.version !== input.sourceVersion) throw new KnowledgeConflictError(input.sourceId);
    const target = this.#resolveTerminalNode(input.targetId);
    if (!target) throw new KnowledgeNotFoundError('node', input.targetId);
    if (target.id === source.id) throw new Error('Cannot create a knowledge merge cycle');
    if (!isKnowledgeScopeVisible(target.scope, source.scope)) {
      throw new Error('Cannot merge a knowledge node into a target that is narrower than its source scope');
    }

    for (const [id, record] of this.#db.knowledgeRecords) {
      if (record.node === source.id) {
        this.#db.knowledgeRecords.set(id, { ...record, node: target.id });
        this.#enqueue('record', id, record.deletedAt ? 'delete' : 'upsert', createKnowledgeUlid(), record.scope);
      }
    }
    for (const [key, mentions] of this.#db.knowledgeMentions) {
      if (mentions.has(source.id)) {
        const next = new Set(mentions);
        next.delete(source.id);
        next.add(target.id);
        this.#db.knowledgeMentions.set(key, next);
        const separator = key.indexOf(':');
        const sourceType = key.slice(0, separator);

View on GitHub (pinned to 75dd419e61)

Solutions

  1. Check input.sourceId !== input.targetId before calling mergeNodes().
  2. Resolve the target's terminal node first (follow mergedInto links) and skip the merge if it equals the source.
  3. 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.
  4. Load fresh node state (versions/mergedInto) before merging to avoid stale-id retries.

Example fix

// before
await storage.mergeNodes({ sourceId: b.id, sourceVersion: b.version, targetId: a.id }); // a already merged into b
// after
const terminal = resolveTerminal(a); // follow mergedInto chain
if (terminal.id !== b.id) {
  await storage.mergeNodes({ sourceId: b.id, sourceVersion: b.version, targetId: terminal.id });
}
Defensive patterns

Strategy: validation

Validate before calling

if (input.sourceId === input.targetId) throw new SkipMerge();
// additionally resolve the target's chain if you track mergedInto yourself
const target = nodes.get(input.targetId);
while (target?.mergedInto) target = nodes.get(target.mergedInto);
if (target?.id === input.sourceId) throw new SkipMerge('would create cycle');

Type guard

function isSafeMergePair(source: KnowledgeNode, target: KnowledgeNode): boolean {
  let t: KnowledgeNode | undefined = target;
  const seen = new Set<string>();
  while (t?.mergedInto) {
    if (t.id === source.id || seen.has(t.id)) return false;
    seen.add(t.id);
    t = t.mergedInto;
  }
  return t?.id !== source.id;
}

Try / catch

try {
  await storage.mergeNodes(input);
} catch (e) {
  if (e.message.includes('merge cycle')) return; // skip: already merged or self-merge
  throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/8aca3d0622f534b2. Report an issue: GitHub.