feat: storage tiering slice 1 (#761): appdata/userdata split + offline migration #784
Closed
kayg
wants to merge 8 commits from
feat/tiering-761-s1 into dev
pull from: feat/tiering-761-s1
merge into: kayg:dev
kayg:main
kayg:feat/style-sheet-fixes
kayg:storage/wal-checkpoint-off-save-1051
kayg:t3/paged-search-reconcile-final
kayg:feat/pass8
kayg:review/calendar-2
kayg:fix/shared-row-swipes
kayg:feat/contrast
kayg:dev
kayg:feat/collections-db
kayg:fix/instant-keys-1056
kayg:feat/money-round
kayg:perf/stalls-1054
kayg:wip/tiering-v3-cold4
kayg:wip/tiering-v3-cold5
kayg:port/rows-actions
kayg:port/rows-drop
kayg:port/hidden-blocks
kayg:port/rows
kayg:port/port-tables
kayg:port/port-tables-2
kayg:t3/mail-1011-liveness
kayg:port/devices
kayg:port/port-attachments
kayg:port/port-attachments-2
kayg:port/port-attachments-3
kayg:docs/design-consistency
kayg:docs/engineering-standards
kayg:fix/rusqlite-0.41-rustsec-2026-0459
kayg:feat/iphone-fixes
kayg:shared/restore-and-stale-918-919
kayg:fix/phone-top-band-contrast
kayg:feat/notes-approve
kayg:perf/soak-max-gate
kayg:feat/files-s1-identity
kayg:fix/lifecycle-event-id
kayg:feat/quickfix
kayg:wip/tiering-v3-cold
kayg:perf/web-mockup-frame-fixes
kayg:port/contacts-merge
kayg:port/contacts-edit
kayg:test/shared-room-no-deleted-text
kayg:perf/first-paint-credits
kayg:fix/shared-history-after-grant
kayg:fix/phone-swipe-regressions
kayg:feat/contrast-r1
kayg:review/sharing-pr3-review
kayg:feat/tiering-v3-s2-tags
kayg:feat/folder-drop
kayg:fix/deskpills-lazy-share
kayg:feat/sharing-s1-pr3
kayg:tmp/money-round-before
kayg:fix/phone-pane-backdrop-roots
kayg:fix/palette-preload-chips
kayg:fix/invite-defaults
kayg:port/tasks-matrix
kayg:fix/restore-green-gates
kayg:fix/sharing-fast-path-hardening
kayg:t3/photos-s1
kayg:feat/tiering-v3-s1-namespace
kayg:notes-archive-hide
kayg:feat/tasks-actions
kayg:t3/photos-plan-slice-one
kayg:feat/whiteboard-s1
kayg:t3/paged-search-reconcile
kayg:t3/smooth-pane-transitions
kayg:t3/index-position-startup-scan
kayg:fix/private-staging-759b-r2
kayg:feat/dav-transport
kayg:feat/imap-notes-fetch
kayg:feat/imap-notes-projection
kayg:feat/imap-notes-readonly
kayg:feat/imap-notes-auth
kayg:feat/imap-notes-server-foundation
kayg:docs/undo-block-ids-plan
kayg:fix/undo-768
kayg:proto/block-identity-v7d
kayg:issue/carddav-read-perf-fix-r2
kayg:issue/carddav-read-r2
kayg:docs/imap-766-plan-v2
kayg:docs/imap-notes-plan
kayg:docs/quota-plan-2
kayg:fix/parallel-test-flakes
kayg:feat/contacts-companion-pure
kayg:docs/calendar-backend-v2-plan
kayg:feat/calendar-ical-converter
kayg:docs/tasks-writes-v2-plan
kayg:docs/carddav-writes-v2-plan
kayg:docs/contacts-import-v2-plan
kayg:issue/610-pm-midnight
kayg:feat/calendar-v2-s1-containment
kayg:docs/dav-recorder-v2-plan
kayg:plan/previews-865
kayg:plan/save-log-durable-entry
kayg:fix/e2e-data-on-ram
kayg:feat/appwindow3
kayg:feat/appwindow2
kayg:feat/appwindow
kayg:feat/money-p1
kayg:feat/keys-sheet
kayg:review/calendar-2-themes
kayg:feat/smooth-cursor
kayg:fix/float-style
kayg:review/calendar-2-float
kayg:release/2026-11-06-hotfix
kayg:archive/port/rows-drop-code
kayg:archive/port/rows-actions
kayg:archive/port/rows
kayg:scratch/tasks-894-ui-gates
kayg:review/calendar-2-night-wip
kayg:release/2026-11-05
kayg:archive/feat/tiering-v3-s1-namespace
kayg:archive/flaky/rows-drop-fixed
kayg:archive/flaky/rows-actions
kayg:archive/feat/tiering-v3-reader
kayg:archive/feat/tiering-v3-roots
kayg:archive/flaky/rows
kayg:sim/bm15-final
kayg:sim/bm15-fix
kayg:sim/bm15-bis-932
kayg:sim/bm15-bis-909
kayg:sim/bm15
kayg:sim/bm15-bis-887
kayg:perf/tiering-option-b
kayg:port/style-3
kayg:port/style-2
kayg:port/style
kayg:fix/sw-lazy-precache
kayg:fix/vim-w-waits-for-save
kayg:fix/signout-test-races
kayg:fp-scratch/all
kayg:sim/bm15-first-paint
kayg:archive/bm15/rows-drop
kayg:archive/bm15/rows-actions
kayg:fix/palette-snippet-contrast
kayg:archive/bm15/rows
kayg:archive/bm15/hidden
kayg:archive/plan/save-log-durable-entry
kayg:mock/money-fix2
kayg:archive/fix/e2e-data-on-ram
kayg:mock/appearance-all
kayg:mock/pill-press
kayg:archive/docs/design-consistency
kayg:mock/auto-swipes
kayg:mock/mail-actions
kayg:archive/feat/mail-p1
kayg:mock/money-static
kayg:archive/review/standards-917
kayg:archive/docs/engineering-standards
kayg:perf/tiering-761-fix
kayg:mock/type-audit
kayg:mock/third-pane
kayg:feat/contacts-import-v2-s1
kayg:perf/executor-hot-path
kayg:archive/port/hidden-blocks
kayg:archive/port/devices
kayg:scratch/executor-on-894
kayg:scratch/executor-spans2
kayg:scratch/executor-spans
kayg:train/2026-11-03-bm14c
kayg:archive/port/rows-drop
kayg:feat/tiering-761-d-scale
kayg:feat/tiering-761-c-index
kayg:feat/tiering-761-b-reader
kayg:archive/review/sharing-pr3-review-9fec3d1f
kayg:train/2026-11-03-bm14b
kayg:perf/ports-lazy-budgets
kayg:feat/tiering-761-a-migration
kayg:train/2026-11-03-bm14
kayg:train/2026-11-03-bm12
kayg:archive/review/sharing-pr3-review
kayg:archive/feat/sharing-s1-pr3
kayg:train/2026-11-03-bm11
kayg:archive/feat/imap-notes-fetch
kayg:train/2026-11-03-bm10
kayg:archive/review/sharing-pr3-review-code
kayg:archive/plan/previews-865
kayg:train/2026-11-02-bm9
kayg:archive/port/port-attachments-3
kayg:archive/port/contacts-merge
kayg:fix/motion-audit-859
kayg:train/2026-11-02-bm8
kayg:archive/scratch/bm8-prep
kayg:archive/port/port-pickers
kayg:archive/port/port-tables-2
kayg:archive/port/port-tables
kayg:archive/feat/sharing-s1-pr3-code
kayg:archive/scratch/bm7-fixed
kayg:train/2026-11-02-bm7
kayg:archive/fix/parallel-test-flakes-code-minipc01
kayg:train/2026-11-02-bm6
kayg:docs/imap-766-readonly-slice
kayg:feat/dav-recorder-v3
kayg:research/788-diagnosis
kayg:archive/fix/parallel-test-flakes
kayg:archive/perf/pr792-cold-index-edit
kayg:docs/sharing-slice-1
kayg:archive/docs/imap-766-plan-v2
kayg:feat/dav-recorder-v2
kayg:archive/docs/dav-recorder-v2-plan
kayg:spike/imap-766-s0-cases
kayg:archive/fix/undo-768
kayg:feat/dav-recorder
kayg:archive/docs/contacts-import-v2-plan
kayg:archive/feat/calendar-ical-converter
kayg:feat/contacts-import
kayg:perf/cold-growth-on-764
kayg:perf/cold-growth-on-tiering
kayg:archive/docs/tasks-writes-v2-plan
kayg:archive/docs/calendar-backend-v2-plan
kayg:archive/feat/contacts-companion-pure
kayg:feat/tasks-set-status
kayg:feat/tasks-move-reorder
kayg:archive/docs/carddav-writes-v2-plan
kayg:archive/feat/calendar-v2-s1-containment
kayg:feat/tasks-delete-promote-duplicate
kayg:archive/feat/dav-transport
kayg:archive/fix/carddav-rebuild-r1
kayg:archive/fix/carddav-retention-r1
kayg:feat/carddav-writes
kayg:perf/quota-on-tiering
kayg:perf/quota-simple
kayg:perf/cold-growth-on-quota-stack
kayg:feat/calendar-identity
kayg:feat/calendar-event-parser
kayg:feat/calendar-props-maps
kayg:port/glass-when-ring
kayg:wip/10by32-01-impl-759b-scratch-20261030
kayg:wip/6by32-01-bm3-webkit-pause-20261030
kayg:wip/10by32-01-bm3-chromium-pause-20261030
kayg:wip/6by32-01-tier-761-s1-pause-20261030
kayg:spike/imap-766-s0
kayg:wip/6by32-02-calternal-pause-20261030
kayg:wip/8by64-01-review-688-pause-20261030
kayg:wip/6by32-01-verify-744-pause-20261030
kayg:wip/6by32-01-verify-742-pause-20261030
kayg:fix/private-staging-759b
kayg:wip/6by32-01-review-748-pause-20261030
kayg:wip/6by32-01-review-733c-pause-20261030
kayg:wip/6by32-01-review-733b-pause-20261030
kayg:wip/6by32-01-perf-idle-8-pause-20261030
kayg:wip/6by32-01-perf767-base-pause-20261030
kayg:wip/6by32-01-perf767b-767-pause-20261030
kayg:wip/6by32-01-perf767b-764-pause-20261030
kayg:wip/6by32-01-perf767-767-pause-20261030
kayg:wip/6by32-01-perf767-764-pause-20261030
kayg:wip/6by32-01-lists-b-pause-20261030
kayg:wip/6by32-01-ids-verify5-pause-20261030
kayg:wip/6by32-01-ids-verify4-pause-20261030
kayg:review/calendar-month
kayg:wip/10by32-02-undo-768-20261030
kayg:test/ids-verify3
kayg:wip/10by32-02-bm3-chromium-20261030
kayg:wip/10by32-01-review-753-20261030
kayg:wip/10by32-01-perf-idle-2-20261030
kayg:wip/10by32-01-bm2-webkit2-20261030
kayg:plan/759b-staging
kayg:train/2026-10-30-bm2
kayg:review/758b-tests
kayg:review/758-tests
kayg:review/764d-tests
kayg:review/767b-tests
kayg:review/preview-2026-10-29
kayg:review/767-tests
kayg:docs/tiering-731-plan
kayg:wip/c6b02-when2
kayg:wip/c6b02-tasks-create-fix5
kayg:wip/c6b02-settings-frost-mock
kayg:wip/c6b02-settings-frost-base
kayg:wip/c6b02-settings-frost
kayg:wip/c6b02-ring
kayg:wip/c6b02-review-733d
kayg:wip/c6b02-bm1-full-a
kayg:wip/compute02-when-field
kayg:wip/compute02-webkit-lists-repro
kayg:wip/compute02-train-bl2-dev
kayg:wip/compute02-dev-green-pw164
kayg:docs/deploy-plan
kayg:review/764c-tests
kayg:review/764b-tests
kayg:review/748g-tests
kayg:review/764-tests
kayg:review/759b-tests
kayg:fix/storage-owner-liveness-642
kayg:fix/storage-fix-7
kayg:wip/compute02-storage-fix-7
kayg:review/759-tests
kayg:feat/tiering-731
kayg:wip/tiering-731-first-attempt
kayg:wip/compute02-storage-fix-1
kayg:review/754g-tests
kayg:perf/idle-honest-2
kayg:review/754f-tests
kayg:review/754e-tests
kayg:review/755-tests
kayg:review/754d-tests
kayg:review/754c-tests
kayg:review/754b-tests
kayg:review/754-tests
kayg:fix/storage-private-tmp-642-dev
kayg:wip/compute02-verify-750
kayg:test/ids-verify5
kayg:test/ids-verify4
kayg:fix/storage-exact-file-quarantine-642
kayg:wip/compute02-verify-740
kayg:perf/one-flush-save
kayg:review/calendar-static
kayg:wip/compute02-verify-737
kayg:review/indexing-ring
kayg:review/when-field-2
kayg:wip/compute02-review-733
kayg:proto/block-identity-2-review
kayg:proto/block-identity-review
kayg:wip/c6b02-tc-design
kayg:review/when-field
kayg:issue/tasks-create-ids
kayg:issue/tasks-create
kayg:review/combined
kayg:wip/compute02-review-combined
kayg:review/task-polish
kayg:review/top-buttons
kayg:review/q1-q5
kayg:train/2026-10-26-bl2
kayg:archive/wt-398h
kayg:archive/train-bm-proptest-seed
kayg:archive/integ325-293
kayg:archive/integ325-ci
kayg:archive/client-auth-review-baseline
kayg:archive/wt-sim-4853
kayg:archive/wt-sim-4811
kayg:archive/wt-448m
kayg:archive/wt-398a-allocator
kayg:issue/calendar-adr
kayg:archive/old/train/2026-10-23bk2
kayg:archive/old/train/2026-10-23bk
kayg:issue/469-mockup-9a
kayg:issue/smart-tasks-mockup
kayg:issue/469-menus
kayg:issue/652-signout-flake
kayg:issue/652-history-fix7
kayg:issue/652-history-fix6
kayg:archive/old/issue/spring-check-perf
kayg:issue/spring-check-perf
kayg:issue/659-rename-fast
kayg:archive/old/train/2026-10-21ar
kayg:issue/search-hardening-3
kayg:issue/652-history-fix5
kayg:issue/660-port-sheets-glass
kayg:issue/652-history-fix4
kayg:issue/652-history-fix3
kayg:issue/652-history-fix2
kayg:issue/652-history-flake
kayg:train/2026-10-21an
kayg:issue/settings-admin-port
kayg:issue/search-port
kayg:train/2026-10-20am
kayg:issue/storage-split-fix2
kayg:issue/storage-split
kayg:archive/old/issue/folder-ids-fix
kayg:train/2026-10-20al
kayg:archive/old/issue/folder-ids
kayg:train/2026-10-20ak
kayg:issue/tables-mockup
kayg:train/2026-10-19aj
kayg:train/2026-10-19ai
kayg:issue/469-merge-fixes
kayg:issue/sidebar-band
kayg:archive/old/issue/608-settings-spec
kayg:train/2026-10-19ah
kayg:issue/swipe-offscreen
kayg:train/2026-10-19ag
kayg:archive/old/issue/590-nlp-adr
kayg:archive/old/issue/587-event-after-write-2
kayg:train/2026-10-19af
kayg:archive/old/issue/587-event-after-write
kayg:issue/469-pill-room-c
kayg:issue/469-pill-room
kayg:train/2026-10-19ae
kayg:issue/469-narrow-handle
kayg:issue/397-ios-swipe-2
kayg:archive/old/issue/adrs-photos-files-tasks
kayg:issue/469-desk-fixes
kayg:archive/old/issue/auto-update-adr
kayg:issue/469-filter-gap
kayg:issue/469-port-1b
kayg:issue/442-opacity-flake
kayg:archive/397-ios-swipe-2
kayg:issue/327-web-fast-3
kayg:archive/old/issue/469-port-1
kayg:issue/469-port-1
kayg:issue/327-web-fast-2
kayg:archive/old/issue/327-web-fast
kayg:archive/old/issue/425-stt-bench-vq1
kayg:archive/old/issue/445-no-bleed
kayg:archive/old/issue/400-swipe-mockup-2
kayg:issue/426-client-review
kayg:issue/426-client-2
kayg:issue/426-client
kayg:archive/old/issue/476-safe-area-lab
kayg:archive/445-top-light-2
kayg:archive/old/issue/445-top-light
kayg:issue/471-blocks-mockup
kayg:archive/old/issue/448-links-app-2
kayg:archive/old/issue/448-links-app
kayg:archive/old/issue/398-cold-links
kayg:issue/434-442-small-2
kayg:issue/434-442-small
kayg:archive/old/issue/398-unload
kayg:archive/old/issue/445-bottom-band
kayg:issue/445-bottom-band
kayg:issue/351-rem-3
kayg:archive/312-views-mockup
kayg:issue/351-rem-2
kayg:issue/351-rem
kayg:archive/old/issue/327-first-note
kayg:issue/395-width-phone
kayg:archive/old/issue/327-typing
kayg:issue/396-ios-edges
kayg:issue/397-ios-swipe
kayg:archive/398-allocator
kayg:archive/old/issue/327-notes-index
kayg:issue/345-347-contrast-empty-2
kayg:archive/old/issue/44-arenas
kayg:issue/44-arenas
kayg:archive/integ325
kayg:archive/old/issue/327-perf-harness
kayg:archive/old/issue/44-thp
kayg:issue/44-thp
kayg:archive/old/issue/285-sticky-pills
kayg:archive/old/issue/44-trim-3
kayg:issue/44-trim-3
kayg:archive/old/issue/165-cards
kayg:archive/old/issue/165-picker-2
kayg:archive/old/issue/212-multi-select
No reviewers
Labels
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
kayg/calternal!784
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/tiering-761-s1"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Refs #761 (plan), #731. Slice 1 of storage tiering: the mandatory appdata/userdata split plus an offline migration command for single-root installs. No tier placement, no HDD moves, no dedupe, no production cutover.
Continues the paused Codex snapshot
wip/6by32-01-tier-761-s1-pause-20261030@b343e0cb(60 files, gates never run). I checked it against the plan and decision; added what was missing (below).Plan recap (what this slice is)
appdata= databases, indexes, auth, cache, admin socket, state lock (e.g. each user'sindex.db, so saves stop triggering quota recounts).userdata=users/<uuid>/...user files, the same logical tree an SSD/HDD pair will later join. One disk:/srv/calternal-app/appdataand/srv/calternal-app/userdata.--userdata/--appdata(+CALTERNAL_USERDATA/CALTERNAL_APPDATA) are canonical;--data/--statestay as aliases. Defaults are./userdataand./appdata; a shared or nested root is refused before anything is created. Startup never migrates a single root, even with--create.docs/plans/0761-slice-1-validation.md.calternal storage split(snapshot) and newcalternal storage unsplit(rollback).Divergences found and fixed vs the plan
storage unsplit: reads both roots, builds<to>.unsplit-partial, and publishes<to>by one atomic no-replace rename; resumable.explain()messages (nothing deleted, how to rerun) and tests.admin.sockleft by a killed server insidesystem/would have made the copy fail on a special file; it is now skipped (a process resource; the lock proves nobody owns it).storage_startup.rs).Failure table -> test map
All in
crates/calternal/src/storage_split.rs(mod tests) unless noted.storage_startup.rs::fresh_install_is_split_from_the_first_start_and_never_nestsappdata_userdata_copy_is_exact_restartable_and_source_retained,storage_startup.rs::cli_split_serves_then_unsplit_rolls_back_to_the_exact_single_root(also starts the real server on the result)failure_at_every_copy_and_first_marker_boundary_recovers_repeatedly,crash_after_owned_directory_and_partial_payload_restartsStorageFullinjected at every boundary: source unchanged, completion marker only after every file, advice printed, rerun completes identicallydisk_full_at_every_step_keeps_the_source_and_resumes_after_space_is_freedPermissionDenied, no completion marker, nothing created, rerun after fixing succeeds (skips itself when run as root)unreadable_source_and_unwritable_destination_fail_closed_then_resumelocks_foreign_destinations_symlinks_and_nested_layout_fail_closedrollback_before_any_new_write_leaves_the_original_ready_for_the_old_binaryrollback_after_new_writes_consolidates_current_state_into_one_root,rollback_survives_failure_at_every_step_and_never_publishes_a_partial_rootrollback_refuses_foreign_pairs_collisions_and_existing_destinations--confirm-stoppedrequired; split/unsplit flags validatedargs.rstestsHow to run the migration by hand
Stop Calternal and every outside writer (sync tools, editors), wait for lingering processes, take a coordinated backup of the whole root, run as the storage owner. Both destination parents must exist; both destination roots must be absent.
Failure: fix the cause (it says what) and rerun the identical command. Rollback before new writes: start the old binary on the untouched original. Rollback after new writes:
Full text:
docs/storage.md.What the prod step needs (not done here; by hand, approved separately)
calternalprocess or lock holder; stop file-sync/outside writers; take and verify a backup of/srv/calternal-app/data.storage splitas the data owner; record the printed verified byte count. Compare withdu -sbof the source.--userdata,--appdata;CALTERNAL_DATA/CALTERNAL_STATEare no longer enough on their own since defaults changed), start, verify sign-in, notes, versions, attachments, quota page./srv/calternal-app/dataand the backup until satisfied. After writes, rollback isunsplit, never "point the old binary at the old copy".Gates
Run on the Linux dev VM inside the repo's Nix dev shell,
-j4, through the slot helper. Gated commit4f0fd265; head differs only by docs.cargo fmt --all --check: passcargo clippy --workspace --all-targets --locked -- -D warnings: passcargo test --workspace --locked --no-fail-fast: 1180 passed, 0 failed, 20 ignored (stress/qualification gates that the plan reserves for the perf VMs)Decisions and open risks
explain()text on ENOSPC/EACCES; staleadmin.sockskipped; split writes three small marker files into the source root (.calternal-split-prepare,.calternal-split-id,.lock), which the old binary ignores. A source with hard links is copied as independent files (no mutation of the source; sharing is lost).docs/plans/0761-slice-1-validation.mdlists the qualification still owed (history-heavy open, 15 ms open, FO1) and cutover stays withheld.unsplitcopies the whole install again; needs free space equal to the data size.🤖 Generated with Claude Code
https://claude.ai/code/session_01Xi6YxsuZ7m2ESGC7aS91AN
Independent review, round 1
Reviewed head
effb44dcagainstorigin/dev. Read the PR body, #761 anddocs/plans/0761-slice-1-validation.md. This was a read-only source review: nothing was built or run (control host). Items marked verify are read from the code and have not been reproduced. Line numbers are in the PR head.Verdict: split the PR, and fix the P0/P1 migration items before anyone runs it on prod. Prod runs
storage splitby hand, so it is a different risk class from the roughly 2,700 lines of runtime change (paged index, pinned reader, scan/watch semantics) that every user gets. Details at the end.P0: can lose, strand or duplicate user data
P0-1. A completed split can be re-run, and re-running resurrects deleted data.
crates/calternal/src/storage_split.rs:27-45, 136, 454. Once split succeeds, the source intent still matches (Ok(old) if old == binding), so the same command proceeds.copy()only checks "target exists and is equal, else differs-refuse". A target that is absent is copied again. Scenario: split, start the new server, user deletes a note or purges Trash, operator re-runs "the identical command" (the PR tells them to on any failure). The deleted file comes back into live userdata, and a file created or moved in the new layout is not touched. Nothing consults.calternal-split-complete. Same for any deletion between a failed first attempt and a retry. Also, a destination is never swept for entries that are not in the source, so stale extras survive a resume.Fix: if
.calternal-split-completeexists, refuse (or run verify-only, never write). Record source file inventory (path, size, hash) in the intent at first run and refuse on any change at resume. Test: split, delete one file in destination, re-run, expect refusal and file still absent.P0-2.
users/<uuid>/contacts/is classified as a Home file, but the code reads it from appdata. verifystorage_split.rs:262-277(is_state_itemallowlist has notes, search, attachments, tasks, index.db*), vscrates/contacts-actions/src/cards.rs:33(Cards::new(user_state).root = user_state/contacts/raw) andwrite.rs:156(self.state.join("users")...). After the split, the original-card copies (unknown fields, original inline photos; the fixture in this very file calls them out) land inuserdata/users/<u>/contacts/rawand the server looks inappdata/.... They are stranded, not deleted, but contacts will treat the originals as missing and may regenerate lossy copies. Tests miss it because the fixture usesNotes/card.vcf, notcontacts/raw.Fix: derive the state list from the code (each plugin's
app_data_dir/ state joins: tasks, contacts, search, attachments, notes, plus any I missed), and add a test that fails if a directory the server creates in appdata is classified as userdata. Better: invert to a startup-written manifest or have a fixture produced by a real server run on a legacy root and then split.Related: a user Home folder literally named
notes,search,tasks,attachmentsorcontacts(case-sensitive fs) is silently moved into appdata, and unsplit refuses it as ambiguous (:200-215). Classify by exact known path plus refusal on collision at split time too.P1: must fix before prod
P1-1. mtimes are not preserved (files or directories).
fsio/src/reader.rscopy_attributescopies mode and xattrs only; nofutimens. Effects: the notes scan state is (size, mtime) per file (notes/src/scan.rs:408-443), so the first start after split sees every note as changed (full cold scan, re-identify of every head, 100k-note cost, risk of spurious "changed outside" events); outside sync tools (Syncthing etc.) will see every file as modified; file managers show wrong dates. The PR body says "modes preserved" only. Fix: copy atime/mtime (nanosecond) on files and, after children, on directories; test it; keep scan state valid.P1-2. "Offline" is only a
.lockflock plus a flag.args.rs(--confirm-stoppedis just a bool). Outside writers are not excluded; the only defense is a re-compare of each file right after its own copy (:482-500). A file edited after it was copied, a file created afterread_dirof its directory, or a file renamed mid-run is not noticed on the first run:source.validate()(:101) checks root identity only, and no final verification pass runs before the completion marker. Fix: after all copies, re-walk the source and compare inventory (path, size, mtime, ino) to the one taken at the start; refuse completion on any difference. Also add a test that mutates the source mid-run (hook inboundary()).P1-3. Marker files in the source root are an operational trap.
.calternal-split-prepare,.calternal-split-idstay in the source forever. A later split to different paths hits "different or torn split intent" (:29-34) with no documented way out; abandoning the split and running the old binary for days, then re-splitting, mixes the P0-1 resume with a changed source. Also: the PR claims the old binary "ignores" them, but the newsplititself rejects unknown root names, and I could not find a test starting the old binary on a marked source. Fix: document exact cleanup; make the intent carry a source inventory hash so a changed source refuses with a clear message; test old-binary start on a marked source (or at leastcheck_legacy).P1-4. Unknown-entry refusal has no override.
:96, :228refuse any unknown root entry. If/srv/calternal-app/datais a mount point,lost+foundis there, and the operator cannot classify it, only delete or move it. Alsocache/is copied in full though regenerable. Add a preflightstorage split --dry-runthat lists classification (counts and bytes per root, every unknown entry, every symlink, hard link, sparse file, special file, non-UTF-8 name) without touching anything, and have the runbook require it on a copy of prod data first.P1-5. Symlinks and special files in user data abort the whole split.
open_inventoryusesNO_SYMLINKSand demands regular files (reader.rs). Safe, but any single symlink in Documents or Photos strands the migration, and note that the same change makes the Notes watcher stop following symlinked folders (notes/src/watch.rs:562-590now usesDirEntry::file_type, which does not follow). verify: a symlinked folder insideNotes/worked ondevand silently disappears from scans after this PR. Decide and document; add tests.P1-6. One bad file poisons the whole Home scan.
watch.rs:588root.open_file(&rel, ...)?insidewalk(): an unreadable note (mode 000, ACL), a file that vanishes between listing and open, or a non-regular entry returnsErrfor the entire walk, soscan::runfails and the Home stays "incomplete". Ondevone such file was skipped. Also non-UTF-8 names are now silently skipped instead of listed (:571-575). Skip-and-report per file instead. Also one extraopenat2per file per poll (snapshot) at 100k notes: measure.P1-7. Operator CLI defaults can write to the wrong place.
args.rs:184, 302, 311, 356:--appdatanow defaults to./appdata(cwd-relative) foruser create,user recover,fix. An operator who runscalternal user create bob --data /srv/calternal-app/data(as in every old runbook) createsauth.dbunder the current directory.require_separatepasses (they are distinct). Fix: require both flags explicitly for non-serve commands, or refuse when only one is given.P1-8. macOS (and any non-Linux) is broken.
reader.rs:Root::open,validate,open_file,open_inventoryreturnUnsupportedoff Linux, andHomenow opens aRootunconditionally (notes/src/home.rs:248). Notes cannot open at all on the Mac VM or a Mac dev machine. If macOS is a supported dev target, gate it; if not, say so in the PR. No macOS gate was run.P2
copy()re-reads the destination aftersync_alland rename, but from page cache, so media errors are not caught; there is no checksum recorded for later audit. A recorded BLAKE3 per file in the intent would make the printed byte count meaningful ("compare withdu -sb" is weak: it ignores holes and hard links).:487-497), so the destination can be larger than the source; the free-space estimate in the runbook ("at least the current data size") is then wrong. Hard links are copied independent (acknowledged); this also inflates space and the quota recount, so say so in the runbook.move_exclusivethen parentsync_dir), butprivate_dirmakes ancestors up to the root withsync_dirper level, which is O(depth) per file and fine, whilecopy_attributeson a directory runs after its children and is followed by onlysync_dir(target), not the parent. A crash there is repaired on resume (fine), but there is no test that observes the order.boundary()returns anErrin-process; no kill -9, no dropped page cache, no reordering. The tests show resumability after clean error returns, not fsync ordering. The PR states this for power cuts; it should say it for crashes too. The tests also skip the permission case when run as root (CI?), and run on a one-user, 8-file fixture: no multi-user, no file above the 1 MiB buffer, no contacts, no hard link, no sparse, no symlink, no mid-run source change, no mtime check.<to>.unsplit-partialfails with "destination differs" if either root changed in between (e.g. server started once), and there is no cleanup instruction, so the rollback path fails when it is needed most. Offer--discard-partialor tell the operator to delete the staging dir (it is owned by the marker). Also unsplit does not collision-check appdata items against userdata items outside the four state names; equal files merge silently (:195-215).Mutex<Connection>is held by the writer during compaction while it deletes and re-inserts every row (:535-560), so all lookups for that Home block for the whole rebuild (seconds at 100k), against the 15 ms goal; (b)log.sendon a boundedsync_channel(64)blocks the calling async thread when the writer is behind an fsync; (c) when the writer thread exits on error, laterput_soon/remove_sooncalls are lost silently (let _ = log.send) and the Home never recovers until restart; (d)linesis restored aslen/70(project), a guess that skews compaction triggers; (e) 128-lease cap refuses a new Home when full: availability regression for a deployment with unload/reload churn, no metric.mnt_id, which changes when the filesystem is remounted; every open then returns PermissionDenied until restart. Intentional per the doc, but needs an operator message and a test of remount.rel_of,free_nameandCmd::Moved(watch.rs,rename.rs): paths are now raw. Existing NFD-named files and existing NFC entries inids.jsonlrely onfoldmatching; verify with an NFD fixture on migrated data (Mac-origin files are the likely case).ids.jsonl) differs, so every Home rebuilds its projection at first open (O(n) per Home). Expected, but budget it in the runbook.P3
owned_roottreats any directory with only.calternal-split-owner.tmpas ours; a stray empty directory created by someone else before the first marker is claimed (:404-415). Low risk because the source intent requires the destination to be absent at first run.fs::read_dirorder anduser.path()use non-canonical paths whileRootis pinned onfrom; fine offline, note it.tree_bytesafter a completed unsplit reports apparent size, unlikecopied.--state, and the OLD binary does not know.calternal-install-id-pairing semantics; confirm it starts on the result in a test (the CLI test starts the new server).Failure table: does each test prove its row?
Err, not durability ordering.Should anything leave slice 1?
Yes. Suggested split, in order:
--userdata/--appdataflags and defaults (fix P1-7),require_separate,storage split/unsplit,DataLockdirectory lock,storage_startup.rs, docs. No notes or index changes. This is what prod will run; keep it reviewable and run it against a copy of real prod data with a dry-run.Rootreader and the noteswatch/scanchanges (symlink, NFC, per-file failure semantics, P1-5/P1-6/P1-8, Linux-only gate).tiering-scaleandtiering_model.rs(no runtime effect; reviewable independently).The
Roottypes are needed by the split (A), so either keep a minimalopen_inventorysubset in A, or land B first.Gates run by the author are green on Linux, but they do not exercise P0-1 or P0-2, which is why they are green.
kayg referenced this pull request2026-10-31 16:18:34 +00:00
Superseded for implementation/review by four stacked PRs, ultimately into
dev:This PR remains open. Nothing was merged or closed, and no production operation was performed. Each replacement PR begins with its plan, sizes/complexity, failure → effect → detection → recovery → test map, exact gate commands, decisions and remaining risks.
calternal_core::layoutlist in migration and state readers. Real-server state-layout fixture passes; round trip puts all 3 original cards in appdata.--unknown-to-appdata(unsplit:--unknown-to-root). Tests pass../appdata. Both aliases and env precedence are tested. Explicit split setup supplies both roots.A validation: dev-01 fmt, clippy and
cargo test -j4 --workspace --locked --no-fail-fast: 1,172 passed, 0 failed, 20 ignored.ci/storage-split-e2e.pyends ALL PASS: 3 users, 211 entries, 160 regular files, 3 contact originals, old times, xattrs, large attachments, symlinks and lost+found. Original → split → immediate unsplit is hash+metadata identical. After server startup/new writes, unsplit is exactly the current roots; original Home files are untouched, and the old binary sees 37 notes (36 + the new note).Linux final gates: all PASS. B: 1,178 passed; C: 1,183 passed; D: 1,186 passed. Each has 0 failed, 20 ignored, fmt and clippy green. Nix only, dev-01 slot, -j4. Logs:
~/tier761-exact-{a,b,c,d}.log, exit files all 0. Exact commands in each PR:cargo fmt --all --check;cargo clippy -j4 --workspace --all-targets --locked -- -D warnings;cargo test -j4 --workspace --locked --no-fail-fast.Final heads: A
c5eaf6af, B977d26c8, C5594f8f5, D5b96787b. Final heads differ from gate-run heads only in one stricter inherited Python assertion; all Rust/workspace sources are identical. A's stricter full round trip also passed (~/tier761-strict-e2e.log, exit 0, fixture~/tier761-roundtrip-strict-6): even installation-marker equality has no exception. Independent review of the replacement stack remains pending. Latest Mac reader/scan checks are still queued under the active shared benchmark lock.Still owed / intentionally not folded into this migration fix: per-file checksum audit persistence and real media/power-loss proof; privileged ENOSPC/inode/mount campaigns; independent SIGKILL/fsync-order testing; id-index compaction mutex latency, async queue backpressure, automatic writer recovery and fair cache-lease eviction/metrics; full 100k/aggregate/100k-edit-history successful-open, 15 ms, RSS/FD/watch and FO1 qualification. Empty/torn-marker staging ownership and offline ancestor/path coordination remain explicit limits. Models in D are independent specifications, not production certification. General Mac suite failures include Linux /proc-dependent shutdown inspection, delayed SQLite cleanup, and an unchanged case-only rename collision fixture. Production preflight/round trip on a stopped backup copy of real data is still required by the runbook; no real production data was accessed here.
Final Mac rerun remains externally blocked after over an hour queued:
/tmp/mac-bench.lockbelongs toBM1-mac-measurement(PID 16512, started 2026-10-31T16:02:50Z). B waiter PID 18599 remains queued; evidence will appear in~/tier761-b-exact-mac.logand~/tier761-b-exact-mac.exit. No foreign lock/process was removed. The latest-head Mac rerun and independent review are still pending.Closing: superseded by #790 (named in the PR body or comments). Branch kept.
Pull request closed