Repository navigation
perf(studio-server): a large project's first history copy takes a fraction of the time - #7415
Conversation
…ction of the time
There was a problem hiding this comment.
Requesting changes at 09236877 for one blocker. The rest looks right.
Blocker: small-file blobs lose permission bits under a restrictive umask. writeFile(temp, bytes, { mode }) applies the process umask to mode. On main, put clones (COPYFILE_FICLONE_FORCE), which keeps the source mode exactly. Only the rare stream fallback was masked. Now every file of 64 KB or less goes through the masked path, and writeTo clones the blob back on restore. So with umask 077 or 027 (common on hardened Linux and shared hosts), a 0755 script is stored as 0700 or 0750, and restoring it changes the file's mode.
Reproduced in a sandbox with umask 0077 on a reflink-capable file system:
- main:
projectHistory.test.ts› "keeps the file's mode" passes. - this head: that existing test fails (
expected 448 to be 493), and so does the newfirstCopy.test.ts› "keeps the same contents and permissions, small files and large" (expected 64 to be 73). - this head with umask 022: all 259 history tests pass.
Suggested fix: after the write, set the mode explicitly, e.g. chmod(temp, mode & 0o7777), or write through a handle and handle.chmod. That costs one more call per file. You could skip it when (mode & ~process.umask()) === (mode & 0o7777). Please also make one test pin the umask, for example by running it under process.umask(0o077) with a restore in finally, so CI catches this. CI's default 022 hides it today.
Checked and OK
- The 'fails without the fix:' line names real tests. I applied each break (slot limit raised to 1000, unguarded
total += size,modedropped fromwriteFile), and each fails the test it names. inFirstCopySlothands a slot directly to the next waiter and only decrements when none is waiting, so it never goes above 4 or leaks a slot.- The SIGKILL test is sound: open sweeps
incoming-*leftovers, and the log is written only afterallSettled. Moving thewhereFolder()check to the end is safe because nothing names the blobs untilpersistLog. putByteshashes the bytes it writes, so a write racing the read can't file bytes under the wrong hash. Thefile()guard fixes the double count when twoputs of the same bytes run at once.__fixtures__/dieMidFirstCopy.tsis not a tsup entry, so it isn't bundled.
Non-blocking
allSettlednow waits for every copy before reporting the first failure. On main the copy stopped at the first one. That's fine, just worth knowing for a project that is moved away mid-copy: the remaining copies run and mostly find no file.
| if (sizes.has(hash)) return hash; | ||
| const temp = join(dir, `incoming-${randomUUID()}`); | ||
| try { | ||
| await withFolderMadeIfMissing(dir, () => writeFile(temp, bytes, { mode })); |
There was a problem hiding this comment.
mode here is masked by the process umask, but the clone this replaces kept the source mode exactly. With umask 077, a 0755 script is stored as 0700, and writeTo clones it back on restore. Set the mode explicitly after the write (chmod(temp, mode & 0o7777) or handle.chmod).
…ry under a strict umask
There was a problem hiding this comment.
Approving at d5a1f47d. This resolves the blocker from my last review.
- After the write,
chmod(temp, mode & 0o7777)sets the stored copy's mode exactly, as the clone path does. - The renamed test runs under
process.umask(0o077)and restores the umask inonTestFinished. I deleted thechmodline and the test failed withexpected 64 to be 73; with the line restored it passes. That makes it a valid 'fails without the fix:' test. - I ran all history tests under umask 077 and under 022, and both runs passed (259 passed, 2 skipped).
- Nothing else changed since
09236877, so my earlier checks still apply.
CI on this head was still running when I posted this.
What changed
Opening a project for the first time starts its history by copying every file into the history store. That copy now takes a fraction of the time on a project with thousands of files, and the history it writes is the same.
Design: none (no UI change)
Why
A project's first open waits for this copy. It took one file at a time, with about seven file operations per file (a stat, two folder creations, a clone, a read to hash the clone, another stat, a rename) and a folder check on the main thread after each.
How
Unchanged: the on-disk format, what the store holds, and the order of the baseline. Small files get their exact mode set after the write (one more call per file), since a write applies the umask and the clone it replaces did not.
What I measured
One rig, before (main) and after (this branch), on GitHub's macos-latest and windows-latest runners, 3 rounds each, alternating. Each project has 4,005 files: 4,000 small scripts, an HTML file, two files with the same bytes, one 200 KB file and one executable script. The rig opens one project's history for the first time, then 11 more at once in one process, while timing a small
staton the side.stat, one openstat, 11 at onceHistory contents are identical before and after on each OS: the baseline of every file and every blob's bytes and permissions hash to the same fingerprint (
478a308d…on macOS,98cba7a6…on Windows), 4,005 files each.Floor and ceiling: a single first open is 2.7 to 5.2 times faster. With 11 at once, half the projects are ready in about half the time, but on macOS the last one finishes in about the same time (one round slower), because the 4 shared slots keep the copies from running wider. The side
statwaited longer on a single Windows open (up to 424 ms against 242 ms) and on a macOS batch, and shorter on a Windows batch.fails without the fix:
firstCopy.test.ts, "copies a few files at a time across every project opening together" fails when the limit is lifted; "keeps the same contents and permissions, small files and large" fails when permissions are dropped;blobStore.test.ts, "counts a file once when two copies of it are filed together" fails with an unguarded count. Each break fails exactly one test."loses nothing when the process dies midway: the next open finishes it" kills a real process with SIGKILL during the copy, with a half-written temp file on disk, then opens again and checks every file is in the baseline with its exact bytes, every blob matches its hash, and no temp file is left. It fails if the log is written before the copy ends, or if half-written temp files aren't swept.
"keeps the same contents and permissions, small files and large, under a strict umask" runs under umask 077 and fails without the explicit mode (
expected 64 to be 73); every history test also passes run under umask 077.blobStore.test.tschanges one line of an existing test: it counts refusals, not rename attempts, because a first rename can now find the folder missing and try once more.What I did NOT exercise