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
- 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.
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
- 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.
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
- Knowledge merge cycle detected at ${node.id}
- Cannot merge a knowledge node into a target that is narrower
- This relationship would create a cycle.
- The related work item chain contains a cycle.
- unavailable
AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30).
Data as JSON: /api/errors/8aca3d0622f534b2.
Report an issue: GitHub.