Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ Requires **Rust 1.88+** (2021 edition).

```bash
make build # build the Rust crates
make test # run every suite (604 Rust tests + 113 Vitest cases)
make test # run every suite (604 Rust tests + 116 Vitest cases)
make test/rust # only the Rust tests that need no Node toolchain (489)
```

Expand Down
43 changes: 40 additions & 3 deletions apps/server-frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,43 @@
// backend, and in development Vite does. That is deliberate, because the
// backend ships no CORS layer and should not need one.

/** How often a running job is polled. */
export const POLL_INTERVAL = 400
/**
* How long to wait before the FIRST re-poll of a job the server has not
* finished yet.
*
* Short on purpose. Nearly every archive is done in less time than a person
* notices, and this loop used to sleep a flat 400 ms before asking a second
* time, so a tiny file spent almost all of its wall clock waiting on the
* client rather than on the server (issue #48).
*
* Not zero: the point is to stop making a finished job wait, not to spin on a
* server that is genuinely busy. These mirror `apps/remote/src/waiting.rs`,
* which fixed the same shape on the Rust side; keep them in step.
*/
export const FIRST_POLL_DELAY = 10

/**
* The ceiling the wait grows to, and the interval a long job settles into.
*
* Deliberately below the old flat 400 ms, and equal to the Rust client's
* ceiling, so the browser no longer waits longer than the CLI for the same
* job.
*/
export const MAX_POLL_DELAY = 200

/**
* The wait before the next poll, given the wait before the last one.
*
* Doubles until it reaches the ceiling: 10, 20, 40, 80, 160, 200, 200, ...
*
* It is not uniformly faster, and that is worth knowing rather than glossing:
* a job that finishes just after the ramp is asked again a whole ceiling
* later, where the flat schedule might have caught it sooner. The band is
* narrow and bounded by one ceiling.
*/
export function nextDelay(previous) {
return Math.min(previous * 2, MAX_POLL_DELAY)
}

/** Read the backend's error shape, falling back to the status line. */
async function failure(response) {
Expand Down Expand Up @@ -75,6 +110,7 @@ export async function compress(
if (!job?.job_id) throw new Error('malformed server response: no job_id')

let last = null
let delay = FIRST_POLL_DELAY
for (;;) {
const polled = await fetcher(`/jobs/${job.job_id}`)
if (!polled.ok) throw await failure(polled)
Expand All @@ -85,7 +121,8 @@ export async function compress(
onStatus(current.status)
}
if (progressOf(current) === 'ready') break
await sleep(POLL_INTERVAL)
await sleep(delay)
delay = nextDelay(delay)
}

onStatus('downloading')
Expand Down
81 changes: 80 additions & 1 deletion apps/server-frontend/tests/api.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, it, expect, vi } from 'vitest'
import { compress, health, progressOf } from '../src/api.js'
import {
compress,
health,
progressOf,
nextDelay,
FIRST_POLL_DELAY,
MAX_POLL_DELAY,
} from '../src/api.js'

/** A fetch stub driven by a list of canned responses, in order. */
function fetcherFrom(responses) {
Expand Down Expand Up @@ -184,3 +191,75 @@ describe('compress', () => {
).rejects.toThrow(/no job_id/)
})
})

describe('the poll schedule', () => {
/**
* Record every delay the loop asks for and fire it at once, so a schedule
* spanning seconds of nominal waiting is tested in no time at all and with
* no wall clock in the assertions.
*/
function captureDelays() {
const waits = []
const real = globalThis.setTimeout
vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn, ms) => {
waits.push(ms)
return real(fn, 0)
})
return waits
}

/** A job that answers `compressing` `n` times before it completes. */
function jobTaking(n) {
const responses = [json({ job_id: 'j' })]
for (let i = 0; i < n; i += 1) responses.push(json({ status: 'compressing' }))
responses.push(json({ status: 'completed' }))
responses.push(json({})) // download
responses.push(json({})) // delete
return fetcherFrom(responses)
}

/**
* Issue #48, the half this file owns. The loop slept a flat 400 ms before
* asking a second time, so a job the server had already finished cost the
* browser that much, and twice what the CLI cost after the Rust half was
* fixed.
*/
it('does not make a job that finishes at once wait out the ceiling', async () => {
const waits = captureDelays()
const { fetcher } = jobTaking(1)

await compress(
{ body: new Blob(['x']), name: 'a.txt', algorithm: 'zip', level: 3 },
{ fetcher },
)

expect(waits).toEqual([FIRST_POLL_DELAY])
expect(FIRST_POLL_DELAY).toBeLessThan(400)
})

it('doubles to the ceiling and then holds', async () => {
const waits = captureDelays()
const { fetcher } = jobTaking(8)

await compress(
{ body: new Blob(['x']), name: 'a.txt', algorithm: 'zip', level: 3 },
{ fetcher },
)

expect(waits).toEqual([10, 20, 40, 80, 160, 200, 200, 200])
expect(Math.max(...waits)).toBe(MAX_POLL_DELAY)
})

/**
* The two clients must not drift. The browser waiting longer than the CLI
* for the same job is exactly what this issue was about.
*/
it('keeps the same schedule the Rust client uses', () => {
expect(FIRST_POLL_DELAY).toBe(10)
expect(MAX_POLL_DELAY).toBe(200)
expect(nextDelay(FIRST_POLL_DELAY)).toBe(20)
expect(nextDelay(160)).toBe(MAX_POLL_DELAY)
expect(nextDelay(MAX_POLL_DELAY)).toBe(MAX_POLL_DELAY)
expect(nextDelay(10_000)).toBe(MAX_POLL_DELAY)
})
})
Loading