Ask the database once which directories a delete shares - #197
Conversation
is_safe_to_delete_dir() ran one query per directory asset:
SELECT count(*) FROM assets
WHERE pkg!=? AND type IN (...) AND data=?
assets carries a single index, assets_pkg (pkg). The pkg!=? inequality
cannot use it and nothing indexes data, so every call was a full scan of
the table -- 944,774 rows and 170 MB on a normal desktop install, about
0.8 seconds each.
Packages with many directories paid that per directory. Deleting
py311-botocore (864 directory assets) took 10m40s of solid CPU, all of
it in that loop; rust, with 1886, projects to roughly 25 minutes. The
delete looked hung, and killing it left the package registered with its
files already unlinked.
Ask the question once instead. The only thing the loop needs is the set
of this package's directories that some other package also owns, which
one query answers:
SELECT DISTINCT data FROM assets
WHERE pkg!=?1 AND type IN (...)
AND data IN (SELECT data FROM assets WHERE pkg=?1 AND type IN (...))
The inner side uses assets_pkg, so only one scan remains for the whole
delete rather than one per directory. is_safe_to_delete_dir() becomes a
bsearch() over the result, which preserves its exact-match semantics.
Memory is bounded by the package's own directory count, not the size of
the assets table: 4 paths for rust, and at most ~100 KiB even if every
directory it owns turned out to be shared.
A failure building the set now aborts the delete before anything is
removed. Previously a failed query in is_safe_to_delete_dir() returned
false, silently leaving directories behind mid-delete.
py312-botocore, 920 directory assets: 10m40s -> 1.6s. Verified that a
package's own directories are removed and shared ones (share/licenses,
lib/python3.12, site-packages) survive, with no spurious "Directory in
use by another package?" warnings.
AI-Assisted-By: Claude Opus 5
Reviewer's GuideThe delete path now queries database ownership once, caches shared directory paths in a sorted in-memory set, and performs fast exact lookups while retaining existing safety checks; query or allocation failures abort before removal. Sequence diagram for cached shared-directory checks during package deletionsequenceDiagram
participant Delete as mport_delete_primative
participant DB as SQLite assets
participant Set as shared_dir_set
participant FS as Filesystem
Delete->>DB: build_shared_dir_set()
DB-->>Delete: shared directory data rows
Delete->>Set: qsort(paths)
loop each directory asset
Delete->>Set: bsearch(asset_path)
alt directory is not shared
Delete->>FS: mport_rmdir(file)
else directory is shared
Delete-->>Delete: retain directory
end
end
Delete->>Set: free_shared_dir_set()
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Claude finished @laffer1's task in 2m 37s —— View job Claude finished reviewing this PRTodo list
ReviewThis is a well-diagnosed fix for a genuine O(n²) hotspot (per-directory full-table scan → one query per delete). I traced the full allocation/error lifecycle and the SQL construction; I did not find correctness or memory-safety bugs. Correctness
Memory safety
Error propagation / behavior change
Splint annotations
Minor, non-blocking observations
Validation
Conclusion: No correctness, memory-safety, SQL-injection, or error-propagation issues found. The fix is sound and matches its stated goal (single index-backed query instead of one scan per directory), with memory usage properly bounded and cleaned up on all paths I traced. |
mport deletelooks hung on packages with many directories. It isn't deadlocked — it is burning CPU in a quadratic loop. Deletingpy311-botocoreon a normal desktop install took 10m40s.Cause
is_safe_to_delete_dir()ran one query per directory asset:assetscarries a single index,assets_pkg (pkg). Thepkg!=?inequality cannot use it and nothing indexesdata, so each call is a full scan:944,774 rows / 170 MB on the test machine, ~0.8 s per call.
py311-botocorehas 864 directory assets → ~11 minutes, which matches the 10m40s measured.gdbon the live process confirmed it, every sample landing insqlite3_stepunderis_safe_to_delete_dir, withprocstat -fshowing onlymaster.dbopen (no package files — it was not hashing).Projected for other installed packages:
rust1886 dirs ≈ 25 min,boost-libs1339 ≈ 18 min,llvm22701 ≈ 9 min.Killing the apparently-hung process makes it worse: file unlinking happens before the transaction that unregisters the package, so an interrupted delete leaves the package registered with its files gone.
Fix
Ask once instead of N times. The loop only needs the set of this package's directories that another package also owns:
The inner side uses
assets_pkg, so one scan covers the whole delete rather than one per directory.is_safe_to_delete_dir()keeps its other guards (root, prefix, system mtree dirs) and ends in absearch()over the sorted result, preserving the old exact-matchdata=?semantics.No new index and no schema migration. An index on
assets(data)would also work but costs +71 MB on a 170 MB database; the partial-index variant is +1.4 MB but SQLite only matches it when the query'sINlist is written in the identical order, which is a trap for the next person to edit the query.Memory is bounded by the package's own directory count, not the size of
assets: 4 paths forrust, at most ~100 KiB even if every directory it owned turned out to be shared.Behavior change
A failure building the set now aborts the delete before anything is removed. Previously a failed query in
is_safe_to_delete_dir()returnedfalse, silently leaving directories behind partway through a delete that otherwise continued.Verification
py312-botocorepy312-jmespath* measured on
py311-botocore(864 dir assets) before the change.Correctness checked both directions on
py312-jmespath, 3 of whose 7 directories are shared with other packages:site-packages/jmespath,.../__pycache__,.../jmespath-0.10.0-py3.12.egg-info,share/licenses/py312-jmespath-0.10.0_1share/licenses,lib/python3.12,lib/python3.12/site-packagesDirectory in use by another package?warningsBuild is clean at
-Werror -Wall -W.precommit_c_sanity.shpasses (cppcheck clean, clang-format no changes).run_splint_on_staged.shcould not run: it aborts preprocessing onmport_lua.hbecause lua'slauxlib.h/luaconf.hare not on its search path. Confirmed pre-existing by running splint against the unmodifiedmainversion of the same file — identical failure. So the splint pass did not actually cover this commit.Summary by Sourcery
Speed up package deletion by batching shared-directory ownership checks before removing package files.
Bug Fixes:
Enhancements: