Skip to content

fix(lifecycle): dotdir_plan scales linearly, and the e2e run cannot touch a real dotdir (#1202) - #1203

Merged
github-actions[bot] merged 3 commits into
mainfrom
fix/issue-1202-dotdir-quadratic
Jul 30, 2026
Merged

fix(lifecycle): dotdir_plan scales linearly, and the e2e run cannot touch a real dotdir (#1202)#1203
github-actions[bot] merged 3 commits into
mainfrom
fix/issue-1202-dotdir-quadratic

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Closes #1202. Two defects that shipped with #1186 (PR #1190, merged earlier today). The second is why the first got through review and CI.

1. dotdir_plan was quadratic

lifecycle.py:919 deduped against a list:

unrecognised.extend(s for s in strays if s not in unrecognised)

s not in unrecognised rescans a collection that grows as the loop runs — O(n²) PosixPath.__eq__ calls over the directory.

strays before after
1,000 0.035s 0.005s
2,000 0.138s 0.010s
4,000 0.538s 0.021s
16,000 0.094s

Doubling the input quadrupled the old time and roughly doubles the new one.

This is user-facing, not just a test problem. ~/.aelfrice/logs/ on a long-lived store reaches five figures — 10,370 on the store this was found against — which puts dotdir_plan past 3.5s on its own, and aelf uninstall runs it twice (once to disclose, once inside dispose_dotdir). The command sat silent before printing anything.

Classification and ordering are unchanged; the list still carries the order, the set only answers membership.

2. The lifecycle e2e test could delete the developer's real ~/.aelfrice/

tests/regression/test_install_uninstall_e2e.py opens with "full lifecycle in a hermetic tmp HOME" but pinned only AELFRICE_DB. The dotdir is not derived from anything the test controls:

# auto_install.py:79
AELFRICE_DOTDIR: Final[Path] = Path.home() / ".aelfrice"

Both uninstall paths read it off the module at call time — cli.py:4996 to list, cli.py:5035 to delete — so uninstall --purge --yes in that test addressed the real home directory and would have removed its install-state sentinels, the LLM consent sentinel, and (under --purge, since include_data is True) the capture logs.

Nothing was actually lost. The 5s pytest-timeout fired inside the quadratic walk and stopped execution before dispose_dotdir — verified, the 10,370 logs and the manifest stamp are intact. That is worth stating plainly: defect 1 is what protected the directory from defect 2, so fixing the performance bug alone would have made the destructive path reachable. That is why both land together.

CI never saw either: a runner's ~/.aelfrice/ is empty, so there was nothing to enumerate (defect 1 stays fast) and nothing to delete (defect 2 is inert).

I audited the rest of the suite — git grep -l '"uninstall"' tests returns four files, but the other three only mention the string in a command-name list. This module is the only one that invokes a disposition, so the blast radius is confined to it.

Verification

  • The scaling test is a real distinguishing assert. Reverting lifecycle.py to the list-based dedup fails it with dotdir_plan scaled 3.8x for 2x the input (0.0367s -> 0.1386s); the fixed version passes at ~2.0x.
  • It asserts the shape of the curve, not a wall-clock budget, so it does not encode how fast this machine is. min of three runs, because load can only make a sample slower. Bar at 3.0x, between the measured 3.8x and 2.0x.
  • A correctness test covers the contract the dedup has to keep: 500 strays each reported exactly once, ordering preserved.
  • The dotdir pin is autouse (--keep-db disposes install state too). One test asserts the pin is in effect, so deleting the fixture fails loudly rather than silently re-arming a destructive run; the lifecycle test writes a stamp into the isolated dotdir and asserts --purge takes it, proving the disposition runs rather than no-ops on an empty directory.
  • Full suite: 6339 passed, 69 skipped, 71 xfailed, 4 xpassed. test_full_lifecycle_setup_onboard_search_uninstall was failing on unmodified github/main before this and now passes in ~1s.

Summary by Sourcery

Fix dotdir lifecycle handling so uninstall runs efficiently on large stores and cannot affect a developer’s real dotdir during tests.

Bug Fixes:

  • Ensure dotdir_plan deduplicates stray paths in linear time so uninstall no longer appears to hang on large log directories.
  • Prevent lifecycle end-to-end uninstall tests from operating on the real ~/.aelfrice dotdir by pinning AELFRICE_DOTDIR to an isolated temporary directory.

Documentation:

  • Document the uninstall performance and destructive-dotdir test issues in the v4 changelog, including their impact and resolution.

Tests:

  • Add regression tests validating dotdir_plan’s correctness on many stray log files and enforcing linear performance characteristics.
  • Extend lifecycle e2e tests to use an isolated dotdir fixture and assert that uninstall --purge disposes the isolated directory rather than the real home.

@robotrocketscience robotrocketscience added the author-Kulili PR coordination mutex label Jul 30, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robotrocketscience, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e407fd14-fe22-4eaa-8ec1-869d84efced5

📥 Commits

Reviewing files that changed from the base of the PR and between fc6f736 and 96f3fd6.

📒 Files selected for processing (4)
  • CHANGELOG/v4.md
  • src/aelfrice/lifecycle.py
  • tests/regression/test_install_uninstall_e2e.py
  • tests/test_uninstall_dotdir.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

Fixes two lifecycle defects: a quadratic stray-path deduplication in dotdir_plan that made uninstall appear to hang on large stores, and an unsafe e2e uninstall test that operated on the developer’s real ~/.aelfrice; adds targeted tests and changelog entry for both behaviors.

Sequence diagram for aelf_uninstall using dotdir_plan with set-based deduplication

sequenceDiagram
    actor User
    participant cli_uninstall as cli_uninstall
    participant lifecycle_dotdir_plan as lifecycle.dotdir_plan
    participant lifecycle_dispose_dotdir as lifecycle.dispose_dotdir
    participant filesystem as filesystem

    User->>cli_uninstall: uninstall --purge --yes
    cli_uninstall->>lifecycle_dotdir_plan: dotdir_plan(AELFRICE_DOTDIR)
    lifecycle_dotdir_plan->>filesystem: home.iterdir()
    lifecycle_dotdir_plan->>filesystem: logs_dir.iterdir()
    lifecycle_dotdir_plan->>lifecycle_dotdir_plan: seen_unrecognised.add(stray)
    lifecycle_dotdir_plan->>lifecycle_dotdir_plan: unrecognised.append(stray)
    lifecycle_dotdir_plan-->>cli_uninstall: plan
    cli_uninstall->>lifecycle_dispose_dotdir: dispose_dotdir(AELFRICE_DOTDIR, plan)
    lifecycle_dispose_dotdir->>filesystem: remove install-state
    lifecycle_dispose_dotdir->>filesystem: remove data (logs)
    lifecycle_dispose_dotdir-->>cli_uninstall: completed
    cli_uninstall-->>User: command finishes without apparent hang
Loading

File-Level Changes

Change Details Files
Make stray-path deduplication in dotdir_plan scale linearly while preserving correctness and ordering.
  • Introduce a seen_unrecognised set to track previously observed stray Paths.
  • Replace list-based membership checks with set membership during classification, appending each newly seen stray to the unrecognised list.
  • Keep planned_set and ordering semantics unchanged so downstream consumers still see the same classification and ordering.
src/aelfrice/lifecycle.py
Add tests to verify dotdir_plan’s correctness and performance characteristics with many stray log files.
  • Add helper _fill_logs to populate a logs/ directory with N stray log files under a synthetic .aelfrice home.
  • Add test_many_strays_are_each_reported_once to assert dedup reports each stray exactly once and preserves ordering.
  • Add test_dotdir_plan_scales_linearly that measures dotdir_plan runtime at 1000 vs 2000 strays, asserting the runtime ratio stays below 3x and thus reflects linear rather than quadratic scaling.
  • Import time.perf_counter for performance measurement in tests.
tests/test_uninstall_dotdir.py
Isolate the dotdir used by the install/uninstall e2e tests from the developer’s real home directory and assert that uninstall operates on this isolated dotdir.
  • Add an autouse isolated_dotdir fixture that builds a disposable ~/.aelfrice path under tmp_path and monkeypatches auto_install.AELFRICE_DOTDIR to point to it.
  • Add test_dotdir_is_isolated_from_the_real_home to assert the patched dotdir differs from Path.home()/".aelfrice", guarding against fixture removal or drift.
  • Update test_full_lifecycle_setup_onboard_search_uninstall to depend on the isolated_dotdir fixture and to write an installed-manifest-version stamp into the isolated dotdir before running uninstall.
  • Assert after uninstall with --purge that both the database and the stamp file are gone, proving that the disposition ran against the isolated directory and did not no-op on an empty dotdir.
tests/regression/test_install_uninstall_e2e.py
Document the lifecycle defects and their fixes in the v4 changelog.
  • Add a detailed bullet describing the quadratic behavior of dotdir_plan on large log directories and its impact on perceived uninstall hangs.
  • Describe the unsafe behavior of the lifecycle e2e test that previously targeted the real ~/.aelfrice, and how pinning the dotdir plus assertions now prevent destructive runs.
  • Note that both defects shipped together and explain why the performance bug initially masked the destructive path.
CHANGELOG/v4.md

Assessment against linked issues

Issue Objective Addressed Explanation
#1202 Ensure dotdir_plan deduplicates using a set instead of a list to remove quadratic behavior while preserving classification and ordering, and add tests that verify correctness and that scaling is linear.
#1202 Make the uninstall dotdir root injectable/overridable so tests can point it at a disposable directory, and update test_install_uninstall_e2e.py to pin the dotdir to tmp_path and assert that the disposition runs against the isolated directory rather than the real ~/.aelfrice/.
#1202 Determine whether any other tests invoke a destructive aelf uninstall mode without pinning the dotdir, and ensure there are no such unpinned destructive tests.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-30T18:48:01Z]

`s not in unrecognised` rescanned a list that grows as the loop runs,
so classifying a directory cost O(n^2) path comparisons: 0.035s at 1k
strays, 0.538s at 4k. `~/.aelfrice/logs/` on a long-lived store reaches
five figures — 10,370 on the store this was found against — which put
`dotdir_plan` past 3.5s on its own. `aelf uninstall` runs it twice, to
disclose and again inside `dispose_dotdir`, so the command looked hung
before printing anything.

Now linear: ~2.0x per doubling, 0.094s at 16k. Classification and
ordering are unchanged; the list still carries the order.

The scaling test asserts the shape of the curve rather than a
wall-clock budget, so it does not encode how fast the machine is —
3.8x before the fix against ~2.0x after, with the bar at 3.0x.
…ne (#1202)

The module docstring claimed a hermetic tmp HOME, but the test pinned
only AELFRICE_DB. `auto_install.AELFRICE_DOTDIR` is built from the real
`Path.home()`, and both uninstall paths read it off the module at call
time — one to list, one to delete. So `uninstall --purge --yes` here
reached the developer's own ~/.aelfrice/ and would have removed its
install-state sentinels and capture logs.

CI never saw it: a runner's ~/.aelfrice/ is empty, so there was nothing
to enumerate and nothing to lose. Locally the 5s timeout fired during
the enumeration walk and stopped execution before the removal, so the
quadratic bug is what kept the directory intact — fixing that alone
would have made this reachable.

Autouse, because --keep-db disposes install state too. A separate test
asserts the fixture is in effect, and the lifecycle test now writes a
stamp into the isolated dotdir and asserts --purge takes it, so the
disposition is proven to run rather than no-op on an empty directory.
@robotrocketscience
robotrocketscience force-pushed the fix/issue-1202-dotdir-quadratic branch from f10d1ba to 96f3fd6 Compare July 30, 2026 18:49
@github-actions

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 115 changed lines (limit: 200)
  • 4 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Approve — and this is my regression. I wrote the quadratic loop, in the #1190 review round, addressing a Sourcery thread about stray reporting. Both findings here are correct, the second one is worse than the first, and the analysis of why they hid each other is right.

I've rebased onto current main (96f3fd63) so it can merge without another round trip.

Corroborated independently

  • 10,372 files in ~/.aelfrice/logs/ on this machine — matching the 10,370 measured. This is not a synthetic scale.
  • Reverting lifecycle.py to the list-based dedup fails test_dotdir_plan_scales_linearly. Removing the AELFRICE_DOTDIR pin fails two tests: the dedicated test_dotdir_is_isolated_from_the_real_home guard and the lifecycle test itself, so the fixture cannot be deleted quietly.
  • 65 tests green across the three affected modules; the lifecycle e2e now runs in 0.16s where it was timing out at 5s.

On "nothing was lost"

Agreed, and here is a second line of evidence for it. installed-manifest-version is still present in my real dotdir. That is install-state, which --purge takes in every mode — so its survival is positive evidence that dispose_dotdir never ran, not just that the timeout fired somewhere. (llm-classify-consented and telemetry.jsonl are absent here, but I have no baseline saying they ever existed on this machine, so I would not read anything into that either way.)

The part worth keeping in front of us

The two defects are not independent, and the write-up is right that the second is why the first survived. But there is a third link: the quadratic loop is the only reason the destructive one didn't fire. A faster implementation of my own change would have deleted the real dotdir on the first local test run. The performance bug was load-bearing safety, by accident.

That inverts the usual severity reading. If someone had "just" fixed the slowness — which is what the symptom looks like from the outside, a hung aelf uninstall — they would have armed the destructive path and shipped it. Landing both together is not tidiness, it is the only safe order, and the PR says so.

What I'd take from it for the review process

My #1190 change was mutation-tested (I reverted the stray-reporting fix and confirmed the new test failed), CI was green, and the full local suite passed. None of that caught either defect, because:

  • The e2e test that would have exercised the real dotdir did fail on my run. I checked whether it failed on clean main, saw that it did, and concluded "pre-existing, not mine" — without reading the traceback. It was pre-existing on main only because main already had my fix(lifecycle): dispose of aelfrice's own artifacts in ~/.aelfrice/ (#1186) #1190 merge in it. The stack frame named lifecycle.py:919, which is a line I wrote.
  • CI could never see it: a runner's ~/.aelfrice/ is empty, so there is nothing to enumerate and nothing to delete. A green matrix was not evidence here.

The concrete lesson is narrow and worth writing down: "fails on main too" only rules out your branch, not your code — and when the failing frame sits in a file you touched today, that is the point to read the traceback rather than the conclusion.

Smaller notes

  • Deduping against a set while the list carries order is exactly right; classification and ordering are unchanged, which the correctness test (500 strays, each once, order preserved) pins separately from the scaling test.
  • Asserting the shape of the curve rather than a wall-clock budget is the right call — it does not encode how fast the machine is, which is what makes the perf tests in test: register --run-perf and let latency budgets govern (#1160) #1197 need a 120s hang-guard rather than a real budget. min of three runs is the correct direction to take the sample.
  • The autouse fixture is right given --keep-db also disposes install state. Easy to get wrong by scoping it to the purge test only.
  • Confining the blast radius by auditing which test modules actually invoke a disposition, rather than which mention "uninstall", is the check I should have run when I added the stray loop.

Labelling once CI settles.

@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 30, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-07-30T18:52:08Z]

@github-actions
github-actions Bot merged commit 96f3fd6 into main Jul 30, 2026
28 checks passed
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 30, 2026
@github-actions

Copy link
Copy Markdown

merge-train: merged 96f3fd6main via FF push.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-Kulili PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(lifecycle): dotdir_plan is quadratic, and the lifecycle e2e test can delete the real ~/.aelfrice

1 participant