#7501·juicefs

SQL: Case-insensitive lookup misses edge ID and cannot fully detect same-inode ABA

Author: zhijian-proCreated Sep 3, 2026Updated Sep 4, 2026

Problem Description

When CaseInsensi is enabled for the SQL metadata engine, if the requested name differs in case from the actual name stored in the database, the initial exact lookup cannot find the edge. The code then obtains the actual name and inode through resolveCase.

However, resolveCase returns an Entry, which does not contain the id of the edge row in the database. As a result, the subsequent CAS can only use:

  • parent
  • name
  • inode

It cannot verify the edge ID.

Therefore, if a directory entry is deleted after being read and a hard link with the same name is recreated using the same inode, the old and new edges have the same inode but different IDs. The current CAS condition still matches the new edge and cannot detect the ABA.

Reproduction Scenario

Assume the database contains:

Foo -> inode 100, edge ID 10

Inode 100 also has other hard links, so it still exists after Foo is deleted.

  1. Client A executes unlink("foo") in case-insensitive mode.

  2. The exact lookup for "foo" fails. resolveCase finds "Foo", but does not return the edge ID.

  3. Client B deletes "Foo" and then recreates the hard link:

    Foo -> inode 100, edge ID 11
  4. Client A deletes the edge using (parent, name, inode).

  5. The condition still matches edge ID 11. Client A incorrectly deletes the directory entry newly created by client B and does not return errEdgeChanged.

Impact

  • SQL metadata engine;
  • CaseInsensi is enabled;
  • paths in doUnlink, doRmdir, and doRename that obtain an edge through resolveCase;
  • in practice, same-inode ABA mainly affects files that still have other hard links.

The edge used by doBatchUnlink comes directly from a database query and already contains the ID, so it is not affected.

Expected Behavior

After obtaining the actual name through resolveCase, the full edge row should be read again using that name within the current SQL transaction to obtain the edge ID.

Subsequent deletes or updates should always use:

parent + name + inode + id

If the edge has been replaced, the number of affected rows should be 0 and errEdgeChanged should be returned, allowing the transaction to re-read the latest state and retry.

Suggested Fix

In the existing case-insensitive fallback branch, query the edge again using the resolved actual name, for example:

go
e = edge{Parent: parent, Name: resolved.Name}
ok, err = s.Get(&e)

The source and destination in doRename need to be handled separately while preserving the existing semantics for case-only renames and hard-link renames involving the same inode.

After the full edge ID is obtained, the related e.Id > 0 fallback checks can be removed so that the CAS condition always includes the ID.

Test Requirements

  • Verify that a case-insensitive lookup obtains a non-zero edge ID;
  • construct an ABA in which an edge with the same name and inode is recreated after deletion, and verify that the old transaction does not delete the new edge;
  • cover Unlink;
  • cover both the source and destination paths of Rename;
  • ensure that the existing tests for case-only renames and hard-link renames involving the same inode continue to pass;
  • consider adding a deterministic transaction-interleaving test under MySQL Repeatable Read.

Related PR

  • #7476