Large pushes into one repository take turns instead of resetting it (#243)
* Large pushes into one repository take turns instead of resetting it
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)Failed
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
7 files changed+251−24
Files (7)
modifiedapps/diffinery/docs/reference/limits.md+1−0View file
| @@ -136,6 +136,7 @@ | ||
| 136 | 136 | | Limit | Value | Applies to | |
| 137 | 137 | | --- | --- | --- | |
| 138 | 138 | | Push size | 100 MB per push | The whole push request after decompression. A larger push is rejected with `push exceeds 100 MB; split it into smaller pushes`. Push in several steps (for example a range of commits at a time). | |
| 139 | +| Pushes to one repository at once | Large ones unpack one at a time | Small pushes (up to 1 MiB) unpack side by side. A larger push needs the repository's full memory share to unpack, so large pushes to the same repository take turns, oldest first; one that waits keeps its connection open and goes through once the push before it is done. Pushes to different repositories don't wait for each other. | | |
| 139 | 140 | | Fetch request size | 16 MB | The request a client sends to fetch or clone (its wants and haves), after decompression. A larger one is refused with `413 fetch request exceeds 16 MB`. | |
| 140 | 141 | | File size in a push | Up to the push size | A single file (blob) can be as large as fits in one push (GitHub allows files up to 100 MB). Files of 4 MiB or more are checked and stored from their compressed bytes, so they never need to fit in memory inflated. | |
| 141 | 142 | | Unpacked size of a push | 512 MiB | What a push's pack inflates to, deltas resolved. A pack that unpacks to more is refused with `pack unpacks to more than 512 MiB; split it into smaller pushes`, however small it is on the wire: a long history of one large file can, since each version counts whole. | |
modifiedapps/diffinery/src/git/push-contents.ts+3−3View file
| @@ -144,12 +144,12 @@ | ||
| 144 | 144 | * A pack staged in R2, unpacked without holding it whole (see StreamedPack): every object is |
| 145 | 145 | * checked as it is resolved, and read again from R2 to be stored. |
| 146 | 146 | */ |
| 147 | -export function stagedContents(store: RepoObjectStore, bucket: R2Bucket, key: string, packOffset: number, packBytes: number, o: { largeObjectBytes: number }): Promise<PushContents> { | |
| 147 | +export function stagedContents(store: RepoObjectStore, bucket: R2Bucket, key: string, packOffset: number, packBytes: number, o: { largeObjectBytes: number; maxHeldBytes?: number }): Promise<PushContents> { | |
| 148 | 148 | return streamedContents(store, r2Source(bucket, key, packOffset, packBytes), o); |
| 149 | 149 | } |
| 150 | 150 | |
| 151 | 151 | /** A pack unpacked from `src` in pieces, one object at a time (see StreamedPack). */ |
| 152 | -export async function streamedContents(store: RepoObjectStore, src: PackSource, o: { largeObjectBytes: number }): Promise<PushContents> { | |
| 152 | +export async function streamedContents(store: RepoObjectStore, src: PackSource, o: { largeObjectBytes: number; maxHeldBytes?: number }): Promise<PushContents> { | |
| 153 | 153 | const packBytes = src.size; |
| 154 | 154 | const hits: SecretHit[] = []; |
| 155 | 155 | const opted: SecretHit[] = []; |
| @@ -157,7 +157,7 @@ | ||
| 157 | 157 | let refusal: string | null = null; |
| 158 | 158 | let pack: StreamedPack | null = null; |
| 159 | 159 | try { |
| 160 | - const p = (pack = await StreamedPack.open(src, { external: repositoryBases(store), largeObjectBytes: o.largeObjectBytes, writesOver: INLINE_LIMIT }, async (obj, content, feed) => { | |
| 160 | + const p = (pack = await StreamedPack.open(src, { external: repositoryBases(store), largeObjectBytes: o.largeObjectBytes, maxHeldBytes: o.maxHeldBytes, writesOver: INLINE_LIMIT }, async (obj, content, feed) => { | |
| 161 | 161 | const why = obj.type === "tree" ? fsckTree(obj.oid, content!, gitmodules) : content ? fsckSize(obj.oid, obj.type, content.length) : null; |
| 162 | 162 | if (why) throw new FsckRefusal(why); |
| 163 | 163 | if (scanned(obj.type)) { |
modifiedapps/diffinery/src/http/git.ts+28−5View file
| @@ -49,8 +49,8 @@ | ||
| 49 | 49 | // A GitHub import's own token, while that import is running and filling this repository, may push in import mode. |
| 50 | 50 | const p = c.get("principal"); |
| 51 | 51 | if (p.type === "account" && p.importId && p.tokenId && (await importPushAllowed(c.env, p.importId, repo.id, p.tokenId))) caller.importId = p.importId; |
| 52 | - const body = await readPushBody(c.env, repo.id, c.req.raw); | |
| 53 | - const out = await receiveWithRetry(c.env, repo.id, stub, body, caller, moved); | |
| 52 | + const { body, release } = await readPushBody(c.env, repo.id, c.req.raw); | |
| 53 | + const out = await receiveWithRetry(c.env, repo.id, stub, body, caller, moved).finally(release); | |
| 54 | 54 | c.executionCtx.waitUntil(c.get("dir").touchPushed(repo.id)); |
| 55 | 55 | return new Response(out, { headers: { "content-type": "application/x-git-receive-pack-result", ...noCache } }); |
| 56 | 56 | }); |
| @@ -120,13 +120,34 @@ | ||
| 120 | 120 | |
| 121 | 121 | /** Pushes up to this size are handed to the repository in memory; larger ones go through R2. */ |
| 122 | 122 | const IN_MEMORY_PUSH = 16 * 1024 * 1024; |
| 123 | +/** | |
| 124 | + * Push bodies one Worker isolate holds in memory at once, across its requests (it has 128 MB, and a | |
| 125 | + * body is held twice while it is joined): past this, a push goes through R2 even under IN_MEMORY_PUSH. | |
| 126 | + */ | |
| 127 | +const ISOLATE_PUSH_BYTES = 32 * 1024 * 1024; | |
| 128 | +let bufferedPushBytes = 0; | |
| 123 | 129 | |
| 124 | 130 | /** |
| 125 | 131 | * A push's body. A large one is staged in R2 as it arrives (a Worker and a Durable Object each |
| 126 | 132 | * have 128 MB of memory, and holding a 70 MB push twice, then copying it to the repository, does |
| 127 | 133 | * not fit); the repository reads it from there once and deletes it. |
| 128 | 134 | */ |
| 129 | -async function readPushBody(env: Env, repoId: string, req: Request): Promise<Uint8Array | SpilledBody> { | |
| 135 | +async function readPushBody(env: Env, repoId: string, req: Request): Promise<{ body: Uint8Array | SpilledBody; release: () => void }> { | |
| 136 | + let counted = 0; | |
| 137 | + const release = () => { | |
| 138 | + bufferedPushBytes -= counted; | |
| 139 | + counted = 0; | |
| 140 | + }; | |
| 141 | + try { | |
| 142 | + return { body: await readPushBodyCounted(env, repoId, req, (n) => ((counted += n), (bufferedPushBytes += n))), release }; | |
| 143 | + } catch (e) { | |
| 144 | + release(); | |
| 145 | + throw e; | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +/** readPushBody's body; `count` is told of every byte held in memory (ISOLATE_PUSH_BYTES). */ | |
| 150 | +async function readPushBodyCounted(env: Env, repoId: string, req: Request, count: (bytes: number) => void): Promise<Uint8Array | SpilledBody> { | |
| 130 | 151 | const tooLarge = "push exceeds 100 MB; split it into smaller pushes"; |
| 131 | 152 | let stream = req.body; |
| 132 | 153 | if (!stream) return new Uint8Array(0); |
| @@ -139,8 +160,10 @@ | ||
| 139 | 160 | if (done) break; |
| 140 | 161 | total += value.length; |
| 141 | 162 | chunks.push(value); |
| 142 | - if (total > IN_MEMORY_PUSH) { | |
| 143 | - // Too big to pass along in memory: what came so far, then the rest, goes to R2. | |
| 163 | + count(value.length); | |
| 164 | + if (total > IN_MEMORY_PUSH || bufferedPushBytes > ISOLATE_PUSH_BYTES) { | |
| 165 | + // Too big to pass along in memory (or this isolate holds enough already): what came so far, then the rest, goes to R2. | |
| 166 | + count(-total); | |
| 144 | 167 | const key = pushBodyKey(repoId); |
| 145 | 168 | const rest = new ReadableStream<Uint8Array>({ |
| 146 | 169 | start(controller) { |
addedapps/diffinery/src/lib/memory-pool.ts+64−0View file
| @@ -1,0 +1,64 @@ | ||
| 1 | +/** A share of a MemoryPool: what one task may hold until it releases it. */ | |
| 2 | +export interface Lease { | |
| 3 | + readonly bytes: number; | |
| 4 | + /** Waits for a larger share: this one is given back first, so nothing it held may still be in use. */ | |
| 5 | + grow(bytes: number): Promise<void>; | |
| 6 | + /** Gives the share back; a second call does nothing. */ | |
| 7 | + release(): void; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Bytes that tasks in one isolate may hold at once (a Durable Object or a Worker has 128 MB, shared | |
| 12 | + * by every request in flight there). take(n) waits until n bytes fit beside what is taken, behind | |
| 13 | + * every earlier waiter, so a large task isn't kept waiting by a stream of small ones. A task asking | |
| 14 | + * for more than the whole pool gets all of it, and runs alone. | |
| 15 | + */ | |
| 16 | +export class MemoryPool { | |
| 17 | + private used = 0; | |
| 18 | + private readonly queue: Array<{ n: number; go: () => void }> = []; | |
| 19 | + | |
| 20 | + constructor(readonly size: number) {} | |
| 21 | + | |
| 22 | + /** Whether `bytes` would be given at once. */ | |
| 23 | + fits(bytes: number): boolean { | |
| 24 | + return !this.queue.length && this.used + Math.min(bytes, this.size) <= this.size; | |
| 25 | + } | |
| 26 | + | |
| 27 | + async take(bytes: number): Promise<Lease> { | |
| 28 | + let n = await this.acquire(bytes); | |
| 29 | + let held = true; | |
| 30 | + const lease: Lease = { | |
| 31 | + get bytes() { | |
| 32 | + return held ? n : 0; | |
| 33 | + }, | |
| 34 | + grow: async (more) => { | |
| 35 | + if (held && more <= n) return; | |
| 36 | + lease.release(); | |
| 37 | + n = await this.acquire(more); | |
| 38 | + held = true; | |
| 39 | + }, | |
| 40 | + release: () => { | |
| 41 | + if (!held) return; | |
| 42 | + held = false; | |
| 43 | + this.used -= n; | |
| 44 | + this.drain(); | |
| 45 | + }, | |
| 46 | + }; | |
| 47 | + return lease; | |
| 48 | + } | |
| 49 | + | |
| 50 | + private async acquire(bytes: number): Promise<number> { | |
| 51 | + const n = Math.min(Math.max(0, bytes), this.size); | |
| 52 | + if (this.fits(n)) this.used += n; | |
| 53 | + else await new Promise<void>((go) => this.queue.push({ n, go })); | |
| 54 | + return n; | |
| 55 | + } | |
| 56 | + | |
| 57 | + private drain(): void { | |
| 58 | + while (this.queue.length && this.used + this.queue[0]!.n <= this.size) { | |
| 59 | + const w = this.queue.shift()!; | |
| 60 | + this.used += w.n; | |
| 61 | + w.go(); | |
| 62 | + } | |
| 63 | + } | |
| 64 | +} |
modifiedapps/diffinery/src/repo/RepoDO.ts+57−16View file
| @@ -27,6 +27,7 @@ | ||
| 27 | 27 | readTree, |
| 28 | 28 | resolvePath, |
| 29 | 29 | TooMuchMemory, |
| 30 | + MAX_HELD_BYTES, | |
| 30 | 31 | serializeCommit, |
| 31 | 32 | walkCommits, |
| 32 | 33 | type Commit, |
| @@ -53,7 +54,8 @@ | ||
| 53 | 54 | import { scanForSecrets, secretBypassReason, type SecretHit } from "../git/secret-scan.js"; |
| 54 | 55 | import { GHOST, HttpError, isValidRefName, now } from "../lib/util.js"; |
| 55 | 56 | import { R2Sink } from "../lib/r2-sink.js"; |
| 56 | -import { archivePrefix, deletePrefixes, repoPrefixes, type SpilledBody } from "../lib/storage-keys.js"; | |
| 57 | +import { archivePrefix, deletePrefixes, pushBodyKey, repoPrefixes, type SpilledBody } from "../lib/storage-keys.js"; | |
| 58 | +import { MemoryPool, type Lease } from "../lib/memory-pool.js"; | |
| 57 | 59 | import { storageRefusal } from "../lib/quotas.js"; |
| 58 | 60 | import { Actions } from "./actions.js"; |
| 59 | 61 | import { AutoMerge, isPerson } from "./auto-merge.js"; |
| @@ -77,6 +79,16 @@ | ||
| 77 | 79 | const MAX_PUSH_BYTES = 100 * 1024 * 1024; |
| 78 | 80 | /** Pushed blobs this large are hashed, scanned and stored from their compressed bytes, never held inflated. */ |
| 79 | 81 | const LARGE_OBJECT_BYTES = 4 * 1024 * 1024; |
| 82 | +/** | |
| 83 | + * What pushes may hold at once in this object while they unpack: one push may hold MAX_HELD_BYTES, besides | |
| 84 | + * a body of up to 16 MiB passed in memory (http/git.ts IN_MEMORY_PUSH), and the object has 128 MB. Without | |
| 85 | + * it, two large pushes unpacking at once could go past that, and the reset drops every request in flight | |
| 86 | + * here, other pushes and fetches included. Each push reserves what it may hold and waits its turn. | |
| 87 | + */ | |
| 88 | +const PUSH_MEMORY_BYTES = MAX_HELD_BYTES + 16 * 1024 * 1024; | |
| 89 | +/** A push with a body this small unpacks within SMALL_PUSH_HELD, beside others; one that needs more waits for a full share. */ | |
| 90 | +const SMALL_PUSH_BYTES = 1024 * 1024; | |
| 91 | +const SMALL_PUSH_HELD = 8 * 1024 * 1024; | |
| 80 | 92 | /** A purge walks every reachable commit and tree while holding the repository: bounded so it finishes in time. */ |
| 81 | 93 | export const MAX_PURGE_OBJECTS = 300_000; |
| 82 | 94 | /** Storage key that marks a deleted repository whose R2 objects are still being deleted. */ |
| @@ -576,10 +588,30 @@ | ||
| 576 | 588 | * to delete: deleted here, a push cut off after this point would find it gone on its retry. |
| 577 | 589 | */ |
| 578 | 590 | async gitReceivePack(input: Uint8Array | SpilledBody, caller: Caller, o: { moved?: boolean; retry?: boolean } = {}): Promise<Uint8Array> { |
| 579 | - return this.receivePack(input, caller, o); | |
| 591 | + const size = input instanceof Uint8Array ? input.length : input.size; | |
| 592 | + const held = size <= SMALL_PUSH_BYTES ? SMALL_PUSH_HELD : MAX_HELD_BYTES; | |
| 593 | + // A large body that has to wait goes to R2 meanwhile, so pushes waiting their turn don't hold their bodies. | |
| 594 | + let staged: string | null = null; | |
| 595 | + if (input instanceof Uint8Array && size > SMALL_PUSH_BYTES && !this.pushMemory.fits(size + held)) { | |
| 596 | + staged = pushBodyKey(this.meta.repoId); | |
| 597 | + await this.env.BUCKET.put(staged, input); | |
| 598 | + input = { spilled: staged, size }; | |
| 599 | + } | |
| 600 | + const body = input instanceof Uint8Array ? size : 0; | |
| 601 | + const lease = await this.pushMemory.take(body + held); | |
| 602 | + try { | |
| 603 | + return await this.receivePack(input, caller, o, { held, grow: () => lease.grow(body + MAX_HELD_BYTES) }); | |
| 604 | + } finally { | |
| 605 | + lease.release(); | |
| 606 | + if (staged) await this.env.BUCKET.delete(staged).catch(() => {}); | |
| 607 | + } | |
| 580 | 608 | } |
| 581 | 609 | |
| 582 | - private async receivePack(input: Uint8Array | SpilledBody, caller: Caller, o: { moved?: boolean; retry?: boolean }): Promise<Uint8Array> { | |
| 610 | + /** What pushes in flight here hold while they unpack (see PUSH_MEMORY_BYTES). */ | |
| 611 | + private readonly pushMemory = new MemoryPool(PUSH_MEMORY_BYTES); | |
| 612 | + | |
| 613 | + /** `memory`: what this push may hold while it unpacks, and how to wait for a full share when a small push needs more. */ | |
| 614 | + private async receivePack(input: Uint8Array | SpilledBody, caller: Caller, o: { moved?: boolean; retry?: boolean }, memory: { held: number; grow: () => Promise<void> }): Promise<Uint8Array> { | |
| 583 | 615 | const size = input instanceof Uint8Array ? input.length : input.size; |
| 584 | 616 | if (size > MAX_PUSH_BYTES) { |
| 585 | 617 | const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null }; |
| @@ -619,20 +651,29 @@ | ||
| 619 | 651 | // Like git's quarantine: nothing from the pack is stored until push protection passes, |
| 620 | 652 | // so a blocked push leaves no objects behind for a retry to build on. |
| 621 | 653 | let contents: PushContents = packedContents(this.store, [], 0); |
| 654 | + const unpack = async (maxHeldBytes: number): Promise<PushContents> => { | |
| 655 | + const opts = { largeObjectBytes: LARGE_OBJECT_BYTES, maxHeldBytes }; | |
| 656 | + if (!(input instanceof Uint8Array)) return packAt < input.size ? stagedContents(this.store, this.env.BUCKET, input.spilled, packAt, input.size - packAt, opts) : contents; | |
| 657 | + const pack = req.pack; | |
| 658 | + if (!pack?.length) return contents; | |
| 659 | + return parsePack(pack, repositoryBases(this.store), opts).then( | |
| 660 | + (objects) => packedContents(this.store, objects, pack.length), | |
| 661 | + // Parsed whole, a pack holds every object at once: one that can't, such as a long history | |
| 662 | + // of a large file, is unpacked in pieces instead, holding only the objects in use. | |
| 663 | + (e: unknown) => { | |
| 664 | + if (!(e instanceof TooMuchMemory)) throw e; | |
| 665 | + return streamedContents(this.store, memorySource(pack), opts); | |
| 666 | + }, | |
| 667 | + ); | |
| 668 | + }; | |
| 622 | 669 | try { |
| 623 | - if (!(input instanceof Uint8Array)) { | |
| 624 | - if (packAt < input.size) contents = await stagedContents(this.store, this.env.BUCKET, input.spilled, packAt, input.size - packAt, { largeObjectBytes: LARGE_OBJECT_BYTES }); | |
| 625 | - } else if (req.pack && req.pack.length > 0) { | |
| 626 | - const pack = req.pack; | |
| 627 | - contents = await parsePack(pack, repositoryBases(this.store), { largeObjectBytes: LARGE_OBJECT_BYTES }).then( | |
| 628 | - (objects) => packedContents(this.store, objects, pack.length), | |
| 629 | - // Parsed whole, a pack holds every object at once: one that can't, such as a long history | |
| 630 | - // of a large file, is unpacked in pieces instead, holding only the objects in use. | |
| 631 | - (e: unknown) => { | |
| 632 | - if (!(e instanceof TooMuchMemory)) throw e; | |
| 633 | - return streamedContents(this.store, memorySource(pack), { largeObjectBytes: LARGE_OBJECT_BYTES }); | |
| 634 | - }, | |
| 635 | - ); | |
| 670 | + try { | |
| 671 | + contents = await unpack(memory.held); | |
| 672 | + } catch (e) { | |
| 673 | + // A small push that needs more than its small share waits for a full one. | |
| 674 | + if (!(e instanceof TooMuchMemory) || memory.held >= MAX_HELD_BYTES) throw e; | |
| 675 | + await memory.grow(); | |
| 676 | + contents = await unpack(MAX_HELD_BYTES); | |
| 636 | 677 | } |
| 637 | 678 | } catch (e) { |
| 638 | 679 | if (resetByDeploy(e)) throw e; |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+38−0View file
| @@ -2932,6 +2932,44 @@ | ||
| 2932 | 2932 | assert.match(gitFails(src, ["push", "-q", `${base}/${me.login}/bigfile.git`, "main"]), /possible secrets detected/); |
| 2933 | 2933 | }); |
| 2934 | 2934 | |
| 2935 | +test("large pushes: three large pushes into one repository at once all go in, and a fetch meanwhile works", async () => { | |
| 2936 | + const me = await ok("GET", "/user"); | |
| 2937 | + await ok("POST", "/user/repos", { name: "pushes-at-once" }); | |
| 2938 | + const remote = `${base}/${me.login}/pushes-at-once.git`; | |
| 2939 | + const src = join(work, "pushes-at-once"); | |
| 2940 | + mkdirSync(src, { recursive: true }); | |
| 2941 | + git(src, ["init", "-q", "-b", "main"]); | |
| 2942 | + writeFileSync(join(src, "README.md"), "base\n"); | |
| 2943 | + git(src, ["add", "-A"]); | |
| 2944 | + git(src, ["commit", "-q", "-m", "base"]); | |
| 2945 | + git(src, ["push", "-q", remote, "main"]); | |
| 2946 | + // Each branch: many medium files over a few commits, so unpacking holds a lot. Two packs stay under | |
| 2947 | + // 16 MB (handed over in memory), one goes over (staged in R2), as when several agents push at once. | |
| 2948 | + const sizes = { a: 180, b: 180, c: 420 }; | |
| 2949 | + const tips = {}; | |
| 2950 | + for (const [branch, count] of Object.entries(sizes)) { | |
| 2951 | + git(src, ["checkout", "-q", "-b", branch, "main"]); | |
| 2952 | + const files = Array.from({ length: count }, () => randomBytes(48 * 1024).toString("base64")); | |
| 2953 | + mkdirSync(join(src, branch), { recursive: true }); | |
| 2954 | + for (let c = 0; c < 3; c++) { | |
| 2955 | + files.forEach((f, i) => writeFileSync(join(src, branch, `f${i}.txt`), `${f}\nrevision ${c}\n${randomBytes(1024).toString("hex")}\n`)); | |
| 2956 | + git(src, ["add", "-A"]); | |
| 2957 | + git(src, ["commit", "-q", "-m", `${branch} ${c}`]); | |
| 2958 | + } | |
| 2959 | + tips[branch] = git(src, ["rev-parse", "HEAD"]).trim(); | |
| 2960 | + } | |
| 2961 | + git(src, ["checkout", "-q", "main"]); | |
| 2962 | + const push = (branch) => gitAsync(src, ["-c", "http.postBuffer=1048576", "push", "-q", remote, branch]); | |
| 2963 | + const fetchWhile = gitAsync(work, ["clone", "-q", remote, join(work, "pushes-at-once-early")]); | |
| 2964 | + await Promise.all([push("a"), push("b"), push("c"), fetchWhile]); | |
| 2965 | + const branches = (await ok("GET", `/repos/${me.login}/pushes-at-once/branches`)).map((b) => b.name).sort(); | |
| 2966 | + assert.deepEqual(branches, ["a", "b", "c", "main"]); | |
| 2967 | + const out = join(work, "pushes-at-once-clone"); | |
| 2968 | + git(work, ["clone", "-q", "--mirror", remote, out]); | |
| 2969 | + for (const [branch, tip] of Object.entries(tips)) assert.equal(git(out, ["rev-parse", `refs/heads/${branch}`]).trim(), tip); | |
| 2970 | + git(out, ["fsck", "--full", "--strict"]); | |
| 2971 | +}); | |
| 2972 | + | |
| 2935 | 2973 | test("large pushes: a 40 MB history of many files goes in one push, read from R2 in ranges, and is still checked", async () => { |
| 2936 | 2974 | const me = await ok("GET", "/user"); |
| 2937 | 2975 | await ok("POST", "/user/repos", { name: "bighistory" }); |
addedapps/diffinery/test/unit/memory-pool.test.ts+60−0View file
| @@ -1,0 +1,60 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { MemoryPool } from "../../src/lib/memory-pool.js"; | |
| 3 | + | |
| 4 | +const tick = () => new Promise((r) => setTimeout(r, 0)); | |
| 5 | + | |
| 6 | +describe("MemoryPool", () => { | |
| 7 | + it("gives shares that fit at once, and makes the rest wait, oldest first", async () => { | |
| 8 | + const pool = new MemoryPool(100); | |
| 9 | + const a = await pool.take(60); | |
| 10 | + const b = await pool.take(30); | |
| 11 | + expect(pool.fits(10)).toBe(true); | |
| 12 | + const order: string[] = []; | |
| 13 | + const c = pool.take(50).then((l) => (order.push("c"), l)); | |
| 14 | + // A small one that would fit still waits behind the large one, so the large one isn't starved. | |
| 15 | + expect(pool.fits(10)).toBe(false); | |
| 16 | + const d = pool.take(10).then((l) => (order.push("d"), l)); | |
| 17 | + await tick(); | |
| 18 | + expect(order).toEqual([]); | |
| 19 | + a.release(); | |
| 20 | + a.release(); // a second release gives nothing back twice | |
| 21 | + await tick(); | |
| 22 | + expect(order).toEqual(["c", "d"]); | |
| 23 | + (await c).release(); | |
| 24 | + (await d).release(); | |
| 25 | + b.release(); | |
| 26 | + expect(pool.fits(100)).toBe(true); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it("a share larger than the pool gets all of it, alone", async () => { | |
| 30 | + const pool = new MemoryPool(100); | |
| 31 | + const small = await pool.take(1); | |
| 32 | + let got = false; | |
| 33 | + const big = pool.take(500).then((l) => ((got = true), l)); | |
| 34 | + await tick(); | |
| 35 | + expect(got).toBe(false); | |
| 36 | + small.release(); | |
| 37 | + const lease = await big; | |
| 38 | + expect(lease.bytes).toBe(100); | |
| 39 | + expect(pool.fits(1)).toBe(false); | |
| 40 | + lease.release(); | |
| 41 | + expect(pool.fits(100)).toBe(true); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it("grows a share by giving it back and waiting its turn for the larger one", async () => { | |
| 45 | + const pool = new MemoryPool(100); | |
| 46 | + const a = await pool.take(10); | |
| 47 | + const b = await pool.take(50); | |
| 48 | + let grown = false; | |
| 49 | + const growing = a.grow(80).then(() => (grown = true)); | |
| 50 | + await tick(); | |
| 51 | + expect(grown).toBe(false); | |
| 52 | + b.release(); | |
| 53 | + await growing; | |
| 54 | + expect(a.bytes).toBe(80); | |
| 55 | + await a.grow(20); // already large enough | |
| 56 | + expect(a.bytes).toBe(80); | |
| 57 | + a.release(); | |
| 58 | + expect(pool.fits(100)).toBe(true); | |
| 59 | + }); | |
| 60 | +}); |