halo-dev/halo · error · ServerWebInputException

Target parent has a cyclic parent chain.

Error message

Target parent has a cyclic parent chain.

What it means

CategoryConsoleService.isDescendant walks a category's parent chain to decide whether a candidate parent is already an ancestor (which would create a cycle when reparenting). It records each visited name in a HashSet and throws ServerWebInputException (HTTP 400) 'Target parent has a cyclic parent chain.' if it revisits a name — i.e. the existing hierarchy already contains a loop.

Source

Thrown at application/src/main/java/run/halo/app/core/endpoint/console/CategoryConsoleService.java:223

                .map(Category.CategorySpec::getPriority)
                .orElse(0);
    }

    private static String parentNameOf(Category category) {
        return normalize(Optional.ofNullable(category.getSpec())
                .map(Category.CategorySpec::getParent)
                .orElse(null));
    }

    private static boolean isDescendant(String candidateName, String ancestorName, Map<String, Category> categoryMap) {
        var current = candidateName;
        var visited = new HashSet<String>();
        while (current != null) {
            if (Objects.equals(current, ancestorName)) {
                return true;
            }
            if (!visited.add(current)) {
                throw new ServerWebInputException("Target parent has a cyclic parent chain.");
            }
            current = Optional.ofNullable(categoryMap.get(current))
                    .map(CategoryConsoleService::parentNameOf)
                    .orElse(null);
        }
        return false;
    }

    private static List<Category> siblings(List<Category> categories, String parentName, String excludingName) {
        return categories.stream()
                .filter(category -> !Objects.equals(category.getMetadata().getName(), excludingName))
                .filter(category -> Objects.equals(parentNameOf(category), parentName))
                .sorted(defaultCategoryComparator())
                .collect(Collectors.toCollection(ArrayList::new));
    }

    private static int indexOf(List<Category> categories, String name) {
        for (int i = 0; i < categories.size(); i++) {

View on GitHub (pinned to d2f5165f9c)

Solutions

  1. Fix the existing loop in the category tree first (inspect each category's spec.parent and break the cycle).
  2. Avoid reparenting a category under one of its own descendants.
  3. Serialize category hierarchy mutations to prevent racing requests from forming a cycle.
  4. Add a repair/admin script that detects and breaks cycles before allowing further reparenting.
Defensive patterns

Strategy: validation

Validate before calling

// Before reparenting, ensure the new parent is not a descendant of the category:
if (isDescendant(newParentName, category.getMetadata().getName(), categoryMap)) {
    return Mono.error(new ServerWebInputException("Target parent has a cyclic parent chain."));
}

Type guard

static boolean hasNoCycle(Map<String, Category> map, String start) {
    var visited = new HashSet<String>();
    var cur = start;
    while (cur != null) {
        if (!visited.add(cur)) return false;
        cur = Optional.ofNullable(map.get(cur))
            .map(CategoryConsoleService::parentNameOf).orElse(null);
    }
    return true;
}

Prevention

When it happens

Trigger: Attempting to set a category's parent such that the walk from the candidate hits a node already in visited, because the current category tree already contains a cycle (e.g. A→B→A created by a bug or direct DB edit), or reparenting a category under one of its own descendants.

Common situations: Data corruption from a prior buggy reparent operation; concurrent reparent requests racing and creating a loop; manual edits to category spec.parent in the database.

Related errors


AI-assisted analysis of halo-dev/halo@d2f5165f9c (2026-08-14). Data as JSON: /api/errors/e0ac239b98c15508. Report an issue: GitHub.